refactoring

This commit is contained in:
2026-05-02 16:24:42 +09:00
parent 296adf3073
commit 859c39fe0c
194 changed files with 5267 additions and 0 deletions

6
core/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
core/ — 순수 비즈니스 로직 패키지
Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다.
모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다.
"""

85
core/change_parser.py Normal file
View File

@@ -0,0 +1,85 @@
"""
core/change_parser.py — 선수 교체 이벤트 파싱
교체 텍스트에서 선수명, 포지션, 교체 유형 등을 추출합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import position_to_defense_no
def extract_change_actor(text: str) -> tuple[str | None, int | None, str]:
"""교체 텍스트의 왼쪽(actor)에서 역할, 타순, 이름 추출
'5번타자 문보경' → ('batter', 5, '문보경')
'투수 임찬규' → ('투수', None, '임찬규')
"""
lhs = (text or "").split(" : ", 1)[0].strip()
batter_match = re.search(r"(\d+)번타자\s+(.+)$", lhs)
if batter_match:
return "batter", int(batter_match.group(1)), batter_match.group(2).strip()
roles = (
"대타", "대주자",
"1루주자", "2루주자", "3루주자", "주자",
"투수", "포수", "1루수", "2루수", "3루수",
"유격수", "좌익수", "중견수", "우익수",
)
for role in roles:
if lhs.startswith(role + " "):
return role, None, lhs[len(role):].strip()
return None, None, lhs
def is_merged_pitcher_substitution(actor_role: str | None, in_role: str | None) -> bool:
"""야수→투수 교체인지 확인 (투수가 DH로 전환되는 병합 교체)"""
field_roles = {"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"}
return actor_role in field_roles and in_role == "투수"
def normalize_change_event(change_event: dict[str, Any]) -> dict[str, Any]:
"""교체 이벤트를 정규화
텍스트 파싱 → actor_name, out_player, in_player, change_type 등 추출
"""
if change_event.get("actor_name") or change_event.get("player_name"):
return change_event
text = change_event.get("text") or ""
normalized = dict(change_event)
normalized["change_type"] = "position_change" if "수비위치 변경" in text else "substitution"
actor_role, bat_order, actor_name = extract_change_actor(text)
normalized["actor_role"] = actor_role
normalized["actor_name"] = actor_name
if bat_order is not None:
normalized["bat_order"] = bat_order
if normalized["change_type"] == "position_change":
rhs = text.split(" : ", 1)[1] if " : " in text else ""
normalized["player_name"] = actor_name
normalized["to_position"] = rhs.split("(으)로", 1)[0].strip()
return normalized
rhs = text.split(" : ", 1)[1] if " : " in text else ""
rhs = rhs.split("(으)로 교체", 1)[0].strip()
in_role, _, in_name = extract_change_actor(rhs)
normalized["out_player"] = actor_name
normalized["in_player"] = in_name
normalized["in_role"] = in_role
pos_defense = position_to_defense_no()
if is_merged_pitcher_substitution(actor_role, in_role):
normalized["change_type"] = "merged_pitcher_substitution"
normalized["player_name"] = actor_name
normalized["to_position"] = "지명타자"
normalized["pitcher_in_player"] = in_name
return normalized
if in_role in pos_defense:
normalized["to_position"] = in_role
return normalized

195
core/config_loader.py Normal file
View File

@@ -0,0 +1,195 @@
"""
config_loader.py — YAML 설정 파일 로딩 + 캐싱
모든 설정 접근의 단일 진입점.
config/ 폴더의 YAML 파일을 로드하고 lru_cache로 캐싱합니다.
YAML 구조: site_label(key) → [alias_1, alias_2, ...] (Closed Set 기반)
조회 시: alias → site_label (역매핑)
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
@lru_cache(maxsize=None)
def load_config(name: str) -> dict[str, Any]:
"""YAML 파일을 로드하여 dict로 반환 (결과 캐싱)"""
path = CONFIG_DIR / f"{name}.yaml"
if not path.exists():
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {path}")
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data
def get_mapping(config_name: str, key: str) -> dict[str, Any]:
"""특정 설정 파일의 특정 섹션을 반환 (원본 구조 그대로)"""
return load_config(config_name).get(key, {})
def get_list(config_name: str, key: str) -> list:
"""특정 설정 파일의 특정 리스트 섹션을 반환"""
return load_config(config_name).get(key, [])
def get_value(config_name: str, key: str, default: Any = None) -> Any:
"""특정 설정 파일의 단일 값을 반환"""
return load_config(config_name).get(key, default)
# ──────────────────────────────────────────────
# Closed Set 역매핑 빌드
# ──────────────────────────────────────────────
@lru_cache(maxsize=None)
def _build_reverse_map(config_name: str, key: str) -> dict[str, str]:
"""site_label: [aliases...] 구조를 {alias: site_label} 역매핑으로 변환
예: { '패스트볼': ['직구', '패스트볼'] }
{ '직구': '패스트볼', '패스트볼': '패스트볼' }
"""
raw = get_mapping(config_name, key)
reverse: dict[str, str] = {}
for site_label, aliases in raw.items():
if isinstance(aliases, list):
for alias in aliases:
reverse[str(alias)] = str(site_label)
else:
# aliases가 리스트가 아닌 경우 (단순 값이면 그대로)
reverse[str(aliases)] = str(site_label)
return reverse
def allowed_values(config_name: str, key: str) -> set[str]:
"""해당 섹션의 관리자 사이트 허용값(Closed Set) 반환"""
raw = get_mapping(config_name, key)
return set(raw.keys())
def lookup(config_name: str, key: str, alias: str) -> str | None:
"""alias → site_label 조회. 없으면 None"""
return _build_reverse_map(config_name, key).get(alias)
def lookup_or_raise(config_name: str, key: str, alias: str) -> str:
"""alias → site_label 조회. 없으면 오류"""
result = lookup(config_name, key, alias)
if result is None:
allowed = allowed_values(config_name, key)
raise ValueError(
f"매핑 오류: '{alias}'{key}의 허용값에 없습니다. "
f"허용값: {sorted(allowed)}"
)
return result
# ──────────────────────────────────────────────
# 편의 함수: 역매핑 (alias → site_label)
# ──────────────────────────────────────────────
def pitch_type_map() -> dict[str, str]:
"""네이버 stuff → 사이트 구종 라벨"""
return _build_reverse_map("pitch_rules", "pitch_type")
def pitch_result_map() -> dict[str, str]:
"""네이버 pitchResultText → 사이트 투구결과 라벨"""
return _build_reverse_map("pitch_rules", "pitch_result")
def batter_result_map() -> dict[str, str]:
"""result.type → 사이트 타자결과 라벨"""
return _build_reverse_map("pitch_rules", "batter_result")
def runner_event_map() -> dict[str, str]:
"""runnerEvent.type → 사이트 주루 라벨"""
return _build_reverse_map("pitch_rules", "runner_event")
def team_name_map() -> dict[str, str]:
"""네이버 팀명 → 사이트 팀명"""
return _build_reverse_map("mappings", "team_name")
def team_code_map() -> dict[str, str]:
"""네이버 팀코드 → 한글 팀명"""
return _build_reverse_map("mappings", "team_code")
def stadium_name_map() -> dict[str, str]:
"""네이버 구장명 → 사이트 구장명"""
return _build_reverse_map("mappings", "stadium_name")
def game_type_map() -> dict[str, str]:
"""네이버 경기유형 → 사이트 경기유형"""
return _build_reverse_map("mappings", "game_type")
def position_number_map() -> dict[str, str]:
"""포지션명 → 번호"""
return _build_reverse_map("mappings", "position_number")
def result_labels() -> dict[str, str]:
"""W/L/H/S → 승리투수/패전투수/홀드/세이브"""
return _build_reverse_map("mappings", "result_labels")
def kbo_sr_id_candidates() -> dict[str, list]:
"""역매핑 불필요 — 원본 그대로"""
return get_mapping("mappings", "kbo_sr_id_candidates")
# ──────────────────────────────────────────────
# 편의 함수: Closed Set 직접 조회
# ──────────────────────────────────────────────
def pitch_type_allowed() -> set[str]:
return allowed_values("pitch_rules", "pitch_type")
def pitch_result_allowed() -> set[str]:
return allowed_values("pitch_rules", "pitch_result")
def batter_result_allowed() -> set[str]:
return allowed_values("pitch_rules", "batter_result")
def runner_event_allowed() -> set[str]:
return allowed_values("pitch_rules", "runner_event")
# ──────────────────────────────────────────────
# 편의 함수: 역매핑 불필요한 것들 (원본 구조 그대로)
# ──────────────────────────────────────────────
def field_coordinates() -> dict[str, list]:
return get_mapping("field_coordinates", "field_coordinates")
def hit_ball_type_map() -> dict[str, str]:
return get_mapping("field_coordinates", "hit_ball_type")
def foul_fly_coords() -> dict[str, list]:
return get_mapping("field_coordinates", "foul_fly")
def defense_button_id_map() -> dict[str, str]:
return get_mapping("site_selectors", "defense_button_id")
def position_to_defense_no() -> dict[str, str]:
return get_mapping("site_selectors", "position_to_defense_no")
def review_result_groups() -> dict[str, dict]:
return get_mapping("review_rules", "review_result_groups")
def crawler_headers() -> dict[str, str]:
return get_mapping("crawler_constants", "headers")
def skip_option_types() -> set[int]:
return set(get_list("crawler_constants", "skip_option_types"))
def hidden_event_texts() -> set[str]:
return set(get_list("crawler_constants", "hidden_event_texts"))
def change_keywords() -> tuple[str, ...]:
return tuple(get_list("crawler_constants", "change_keywords"))
def max_inning() -> int:
return get_value("crawler_constants", "max_inning", 20)

255
core/field_calculator.py Normal file
View File

@@ -0,0 +1,255 @@
"""
core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산
필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음.
"""
from __future__ import annotations
import math
import re
from typing import Any
from core.config_loader import (
field_coordinates,
hit_ball_type_map,
foul_fly_coords,
position_number_map,
)
# ──────────────────────────────────────────────
# 타구 종류 추론
# ──────────────────────────────────────────────
def infer_hit_ball_type(result_text: str) -> str:
"""결과 텍스트에서 타구 종류 추론
'2루수 땅볼 아웃''땅볼'
'좌익수 뒤 2루타''일반바운드'
"""
if "번트" in result_text:
return "번트타구"
if "몸에 맞는 타구" in result_text:
return "땅볼"
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
return "플라이"
if "파울플라이" in result_text:
return "플라이"
if "라인드라이브" in result_text or "직선타" in result_text:
return "라인드라이브"
if "플라이" in result_text:
return "플라이"
if "땅볼" in result_text:
return "땅볼"
if "홈런" in result_text:
return "홈런성타구"
return "일반바운드"
def get_hit_ball_type_code(hit_ball_type: str) -> str:
"""타구 종류 라벨 → 사이트 value 코드"""
return hit_ball_type_map().get(hit_ball_type, "1")
# ──────────────────────────────────────────────
# 필드 존 추론
# ──────────────────────────────────────────────
ORDERED_ZONES = (
"좌중간", "우중간",
"좌전", "중전", "우전",
"좌월", "중월", "우월",
"좌익수", "중견수", "우익수",
"유격수", "3루수", "2루수", "1루수",
"투수", "포수",
)
def infer_field_zone(result_text: str) -> str:
"""결과 텍스트에서 타구 방향(zone) 추론
'우익수 앞 1루타''우익수'
"""
if "몸에 맞는 타구" in result_text:
return "1루수"
for zone in ORDERED_ZONES:
if zone in result_text:
return zone
return "중견수"
def extract_direction_offsets(result_text: str) -> tuple[int, int]:
"""결과 텍스트에서 방향 오프셋 추출
'좌익수 왼쪽 뒤' → (-1, -1)
"""
x_delta = 0
y_delta = 0
if "왼쪽" in result_text:
x_delta -= 1
if "오른쪽" in result_text:
x_delta += 1
if "" in result_text:
y_delta += 1
if "" in result_text:
y_delta -= 1
return x_delta, y_delta
def is_infield_zone(zone: str) -> bool:
"""내야 존인지 확인"""
return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"}
# ──────────────────────────────────────────────
# 좌표 계산
# ──────────────────────────────────────────────
def get_zone_coordinates(zone: str) -> tuple[int, int]:
"""존 이름 → (x, y) 퍼센트 좌표"""
coords = field_coordinates()
coord = coords.get(zone, coords.get("중견수", [50, 24]))
return tuple(coord)
def get_foul_fly_coordinates(side: str) -> tuple[int, int]:
"""파울 플라이 좌표 ('left' 또는 'right')"""
coords = foul_fly_coords()
return tuple(coords.get(side, [50, 70]))
def calculate_hit_ball_coordinates(
result_text: str,
zone: str | None = None,
) -> tuple[int, int]:
"""결과 텍스트로부터 타구 좌표 계산
Returns: (x, y) 퍼센트 좌표
"""
if zone is None:
zone = infer_field_zone(result_text)
x, y = get_zone_coordinates(zone)
x_delta, y_delta = extract_direction_offsets(result_text)
infield = is_infield_zone(zone)
step = 3 if infield else 5
x += x_delta * step
y += y_delta * step
# 범위 제한
x = max(0, min(100, x))
y = max(0, min(100, y))
return x, y
def calculate_distance(x: int, y: int, meter_per_px: float) -> float:
"""좌표에서 홈까지의 거리 계산 (미터)"""
home_x, home_y = 50, 93
dx = (x - home_x) * meter_per_px
dy = (y - home_y) * meter_per_px
return math.sqrt(dx * dx + dy * dy)
# ──────────────────────────────────────────────
# 수비 시퀀스 추출
# ──────────────────────────────────────────────
def _position_label_map() -> dict[str, str]:
"""번호 → 포지션명 역매핑"""
return {v: k for k, v in position_number_map().items()}
def extract_defense_sequence(result_text: str) -> list[str]:
"""결과 텍스트에서 수비 시퀀스 추출
'2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수']
"""
pos_label = _position_label_map()
# 1) '2-6', '2-5-3' 같은 숫자 패턴
num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text)
if num_seq_match:
nums = num_seq_match.group(1).split("-")
pos_names = [pos_label[n] for n in nums if n in pos_label]
if pos_names:
return pos_names
# 2) 괄호 안에서 포지션 추출
parenthetical_match = re.search(r"\(([^)]*)\)", result_text)
if parenthetical_match:
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
parenthetical_match.group(1),
)
if sequence:
return sequence
# 3) 괄호 앞 본문에서 포지션 추출
leading_text = result_text.split("(", 1)[0]
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
leading_text,
)
if sequence:
return sequence
# 4) 존에서 폴백
zone = infer_field_zone(result_text)
pos_num = position_number_map()
if zone in pos_num:
return [zone]
return []
def extract_error_position(result_text: str) -> str | None:
"""실책 관련 텍스트에서 실책 수비자 포지션 추출"""
parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text)
search_texts = [parenthetical_match.group(1)] if parenthetical_match else []
search_texts.append(result_text)
for text in search_texts:
positions = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
text,
)
if positions:
return positions[0]
return None
def infer_error_position_fallback(text: str) -> str:
"""실책 포지션 추론 폴백"""
if "야수선택" in text:
return "야수선택"
if "도루" in text:
return "포수"
if "포구" in text:
return "포수"
if "송구" in text:
return "투수"
return "포수"
def is_error_result(result_text: str) -> bool:
"""실책 결과인지 확인"""
return "실책" in result_text
def is_throwing_error(result_text: str) -> bool:
"""송구 실책인지 확인"""
keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러")
return any(keyword in result_text for keyword in keywords)
def is_double_play_result(result_text: str) -> bool:
"""병살인지 확인"""
return "병살" in result_text
def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]:
"""병살 이벤트의 첫 번째 수비 시퀀스"""
result_text = ((event.get("result") or {}).get("text") or "").strip()
return extract_defense_sequence(result_text)

150
core/normalizer.py Normal file
View File

@@ -0,0 +1,150 @@
"""
core/normalizer.py — 모든 정규화 함수의 단일 진입점
팀명, 구장, 포지션, 선수명, 경기유형 등의 정규화를 담당합니다.
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import (
team_name_map,
team_code_map,
stadium_name_map,
game_type_map,
position_number_map,
position_to_defense_no,
)
# ──────────────────────────────────────────────
# 팀/구장/경기유형 정규화
# ──────────────────────────────────────────────
def normalize_team_name(name: str) -> str:
"""팀명 정규화 (네이버 표기 → 관리자 사이트 표기)"""
return team_name_map().get(name, name)
def normalize_team_code(code: str) -> str:
"""팀 코드 → 한글 팀명"""
return team_code_map().get(code, code)
def normalize_game_type(name: str) -> str:
"""경기 유형 정규화"""
return game_type_map().get(name, name)
def normalize_stadium_name(name: str) -> str:
"""구장명 정규화 (네이버 표기 → 관리자 사이트 select 라벨)"""
return stadium_name_map().get(name, name)
def normalize_position_to_number(position: str) -> str:
"""포지션명 → 번호 문자열 (투수→1, 포수→2, ...)"""
return position_number_map().get(position, "")
def normalize_position_to_defense_no(position: str) -> str:
"""포지션명 → 수비번호 (라인업 select 옵션 value)"""
return position_to_defense_no().get(position, "")
def position_label_from_number(number: str) -> str:
"""수비번호 → 포지션명 (역매핑)"""
pos_map = position_number_map()
reverse = {v: k for k, v in pos_map.items()}
return reverse.get(number, "")
# ──────────────────────────────────────────────
# 선수명/번호 정규화
# ──────────────────────────────────────────────
def normalize_player_name(name: str | None) -> str:
"""선수명 정규화: *, 괄호 내용 제거"""
text = (name or "").replace("*", "").strip()
text = re.sub(r"\([^)]*\)\s*$", "", text).strip()
return text
def normalize_lineup_text(text: str) -> str:
"""라인업 텍스트에서 순수 이름만 추출
'[10] 문보경' / '문보경 [10번]' 등 → '문보경'
"""
text = (text or "").strip()
text = text.replace("*", "")
text = re.sub(r"\[\d+(?:번)?\]", "", text)
text = re.sub(r"\s*\(.*?\)\s*", "", text)
text = "".join(re.findall(r"[가-힣A-Za-z]+", text))
return text.strip()
def normalize_number_text(number: str | int | None) -> str:
"""등번호 정규화: 숫자만 추출"""
text = str(number or "").strip()
digits = "".join(char for char in text if char.isdigit())
if not digits:
return ""
return str(int(digits))
def normalize_option_player_text(text: str) -> tuple[str, str]:
"""select option 텍스트에서 선수명과 번호 분리
'문보경 [10번]' → ('문보경', '10')
"""
stripped = " ".join(text.split())
matched = re.match(r"^(.*?)\s*\[(\d+)번\]$", stripped)
if matched:
return normalize_player_name(matched.group(1)), normalize_number_text(matched.group(2))
return normalize_player_name(stripped), ""
# ──────────────────────────────────────────────
# 시간 유틸
# ──────────────────────────────────────────────
def split_time(iso_time: str | None) -> tuple[str, str]:
"""ISO 시간 문자열에서 시/분 분리
'2026-04-14T18:30:00' → ('18', '30')
"""
if not iso_time:
return "00", "00"
from datetime import datetime
dt = datetime.fromisoformat(iso_time)
return f"{dt.hour:02d}", f"{dt.minute:02d}"
# ──────────────────────────────────────────────
# 텍스트 추론 유틸
# ──────────────────────────────────────────────
def infer_option_role_hint(text: str) -> str:
"""select option 텍스트에서 역할 힌트 추출
'문보경 (투) [10번]''pitcher'
'문보경 (타)''batter'
"""
stripped = " ".join(text.split())
matched = re.search(r"\(([^)]*)\)\s*(?:\[\d+번\])?$", stripped)
if not matched:
return ""
hint = matched.group(1).strip()
if hint == "":
return "pitcher"
if hint == "":
return "batter"
return ""
def infer_target_role_hint(position_name: str | None) -> str:
"""포지션명에서 역할 힌트 추론"""
if position_name == "투수":
return "pitcher"
return "batter"

273
core/pitch_classifier.py Normal file
View File

@@ -0,0 +1,273 @@
"""
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()

131
core/review_parser.py Normal file
View File

@@ -0,0 +1,131 @@
"""
core/review_parser.py — 합의판정/비디오판독 파싱
판독 텍스트에서 항목, 원래 판정, 최종 판정 등을 추출합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import review_result_groups
def infer_review_item(detail_text: str) -> str:
"""판독 텍스트에서 사이트 표준 항목 추론
'홈런 파울 판정''홈런타구 페어 파울'
"""
dt = detail_text.replace(" ", "")
if "홈런" in dt:
return "홈런타구 페어 파울"
if "아웃" in dt or "세이프" in dt or "포스" in dt or "태그" in dt or "견제" in dt or "도루" in dt:
return "포수/태그플레이 아웃/세이프"
if "페어" in dt or "파울" in dt:
return "외야타구 페어 파울"
if "포구" in dt or "노바운드" in dt or "바운드" in dt:
return "야수의 포구"
if "몸에맞" in dt or "데드볼" in dt:
return "몸에 맞는 볼"
if "헛스윙" in dt or "스윙" in dt:
return "헛스윙"
return "기타"
def normalize_review_result_token(token: str, review_item: str) -> str | None:
"""판독 결과 토큰을 정규화
'세이프''세이프', '노스윙''불인정'
"""
token = (token or "").strip()
if not token:
return None
if review_item in {"홈런타구 페어 파울", "외야타구 페어 파울"}:
if "페어" in token:
return "페어"
if "파울" in token:
return "파울"
elif review_item in {"포수/태그플레이 아웃/세이프", "야수의 포구"}:
if "아웃" in token:
return "아웃"
if "세이프" in token:
return "세이프"
elif review_item == "헛스윙":
# '노스윙'에도 '스윙'이 포함되므로 먼저 체크
if "불인정" in token or "노스윙" in token or "공포" in token or "노 스윙" in token:
return "불인정"
if "스윙" in token or "인정" in token:
return "인정"
else:
if "불인정" in token or "실패" in token:
return "불인정"
if "인정" in token:
return "인정"
return token # 모르는 키워드 → 원문 그대로
return None
def parse_review_event_text(text: str) -> dict[str, Any]:
"""판독 텍스트를 파싱하여 구조화된 dict로 변환
입력 예: '6회초 8번타순 1구 후 18:45 ~ 18:46 (1분간) LG요청
비디오 판독: 안중열 포스아웃 관련 세이프→세이프'
"""
inning_match = re.search(r"(\d+)회(초|말)", text)
request_team_match = re.search(r"([가-힣A-Za-z]+)요청\s*(?:비디오 판독|합의 판정)", text)
# '→노 스윙' 같은 공백 정규화
normalized = re.sub(r"→([가-힣]+)\s+([가-힣]+)", r"\1\2", text)
detail_match = re.search(
r"(?:비디오 판독|합의 판정):\s*(.+?)\s*([가-힣]+)→([가-힣]+)\s*$",
normalized,
)
detail_text = detail_match.group(1).strip() if detail_match else text
review_item = infer_review_item(detail_text)
before_result = normalize_review_result_token(detail_match.group(2), review_item) if detail_match else None
after_result = normalize_review_result_token(detail_match.group(3), review_item) if detail_match else None
return {
"type": "video_review",
"text": text,
"requestInningLabel": (
f"{inning_match.group(1)}{'' if inning_match.group(2) == '' else ''}"
if inning_match else None
),
"requestTeam": request_team_match.group(1) if request_team_match else None,
"reviewItem": review_item,
"beforeResult": before_result,
"finalResult": after_result,
"isSuccess": (
"성공" if before_result and after_result and before_result != after_result
else "실패"
),
"timing": "before_pitch" if "초구 전" in text else "after_pitch",
}
def normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]:
"""판독 이벤트를 정규화
beforeResult/finalResult가 누락된 경우 텍스트에서 재파싱
"""
has_results = (
review_event.get("beforeResult") is not None
and review_event.get("finalResult") is not None
)
if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results:
return review_event
text = review_event.get("text") or ""
parsed = parse_review_event_text(text)
parsed.update({k: v for k, v in review_event.items() if k not in parsed})
return parsed
def get_review_result_group(review_item: str) -> dict[str, Any] | None:
"""사이트에서 판독항목에 대응하는 결과 그룹 정보 반환"""
groups = review_result_groups()
return groups.get(review_item)

133
core/runner_classifier.py Normal file
View File

@@ -0,0 +1,133 @@
"""
core/runner_classifier.py — 주루 이벤트 분류
네이버 리포트의 주루 이벤트를 분석하여 관리자 사이트에서
선택해야 할 라디오 버튼 라벨을 결정합니다.
"""
from __future__ import annotations
from typing import Any
from core.config_loader import runner_event_map
def classify_runner_event(event_type: str) -> str | None:
"""주루 이벤트 타입 → 사이트 라벨 (기본 매핑)"""
return runner_event_map().get(event_type or "")
def infer_runner_action_label(
event: dict[str, Any],
runner_event: dict[str, Any],
) -> str | None:
"""주루 이벤트를 종합적으로 추론하여 사이트 라벨 반환
리포트 action_label, event_type, event_text, result_type 등을 모두 분석.
"""
# 0. 리포트에 명시된 라벨이 있으면 최우선
if "action_label" in runner_event:
return runner_event["action_label"]
event_type = runner_event.get("type") or ""
event_text = runner_event.get("text") or ""
result_type = ((event.get("result") or {}).get("type") or "")
result_text = ((event.get("result") or {}).get("text") or "")
# 이중도루 실패 + 진루
if "이중도루 실패" in event_text and "진루" in event_text:
return "기타 진루"
if "도루" in event_text and "실패" in event_text and "진루" in event_text:
return "기타 진루"
# 견제 아웃
if event_type == "pickoff_out" or "견제사" in event_text:
return "견제 아웃"
# 도루 실패
if event_type == "steal_fail":
return "도루시도 아웃"
if "이중도루 실패" in event_text and "아웃" in event_text:
return "도루시도 아웃"
# 도루 + 실책 진루
if "도루" in event_text and "실책" in event_text and ("진루" in event_text or event_type == "error_advance"):
return "도루성공&실책"
# 도루
if "도루" in event_text:
if "실패" in event_text:
return "도루시도 아웃"
return "도루성공"
# 낫아웃 + 폭투/포일
if "낫아웃" in result_text and event_type == "wild_pitch_advance":
return "폭투 낫아웃 진루"
if "낫아웃" in result_text and event_type == "passed_ball_advance":
return "포일 낫아웃 진루"
# 포일 진루
if "포일" in event_text and ("진루" in event_text or event_type == "passed_ball_advance"):
return "포일-진루성공"
# 실책으로 진루
if "실책으로" in event_text:
return "수비 실책"
# 안타/아웃 상황 → 일반 진루
play_types = {
"single", "double", "triple", "home_run", "out", "strikeout",
"play", "sacrifice_fly", "sacrifice_bunt", "ground_out", "fly_out",
}
if result_type in play_types and event_type in {"advance", "score"}:
return "일반 진루"
# 볼넷 상황 → 볼넷 진루
walk_types = {"walk", "intentional_walk", "hit_by_pitch"}
if result_type in walk_types and event_type in {"advance", "score"}:
return "볼넷 진루"
# 기본: 일반 진루
if event_type in {"advance", "score"}:
return "일반 진루"
# 최종 폴백: config 매핑
return classify_runner_event(event_type)
def get_runner_area_type(event: dict[str, Any], runner_event: dict[str, Any]) -> int:
"""주루 이벤트의 입력 영역 타입 결정
1 = 진루 영역 (일반 진루, 볼넷 진루 등)
2 = 액션 영역 (도루, 견제, 폭투, 포일 등)
"""
event_text = runner_event.get("text") or ""
action_keywords = ["도루", "견제", "폭투", "포일", "태그아웃", "포스아웃"]
if any(k in event_text for k in action_keywords):
return 2
return 1
def split_complex_runner_event(
runner_event: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""복합 주루 이벤트를 두 개로 분리
예: '도루성공 후 수비실책 진루' → (도루, 실책진루)
"""
text = runner_event.get("text") or ""
if "실책" not in text and "/" not in text:
return runner_event, None
# '도루성공&실책' 같은 패턴
if "도루" in text and "실책" in text and "진루" in text:
first = dict(runner_event)
first["type"] = "steal"
first["text"] = text
second = dict(runner_event)
second["type"] = "error_advance"
second["text"] = text
return first, second
return runner_event, None