refactoring
This commit is contained in:
6
core/__init__.py
Normal file
6
core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
core/ — 순수 비즈니스 로직 패키지
|
||||
|
||||
Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다.
|
||||
모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다.
|
||||
"""
|
||||
85
core/change_parser.py
Normal file
85
core/change_parser.py
Normal 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
195
core/config_loader.py
Normal 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
255
core/field_calculator.py
Normal 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
150
core/normalizer.py
Normal 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
273
core/pitch_classifier.py
Normal 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
131
core/review_parser.py
Normal 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
133
core/runner_classifier.py
Normal 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
|
||||
Reference in New Issue
Block a user