refactoring
This commit is contained in:
322
automation/batter_input.py
Normal file
322
automation/batter_input.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
automation/batter_input.py — 타석 결과 입력
|
||||
|
||||
타석의 결과(타구 좌표, 타격 결과, 타자 진루)를 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.pitch_classifier import infer_batter_result_label, is_ball_in_play_event
|
||||
from core.field_calculator import (
|
||||
infer_hit_ball_type,
|
||||
infer_field_zone,
|
||||
get_hit_ball_type_code,
|
||||
get_zone_coordinates,
|
||||
get_foul_fly_coordinates,
|
||||
extract_direction_offsets,
|
||||
is_infield_zone,
|
||||
)
|
||||
from automation.page_helpers import (
|
||||
click_radio_by_label,
|
||||
get_checked_event_name,
|
||||
get_last_visible_enabled_locator,
|
||||
)
|
||||
from automation.defense_popup import (
|
||||
fill_error_defense_popup,
|
||||
click_defense_sequence_in_popup,
|
||||
fill_runner_out_defense,
|
||||
)
|
||||
|
||||
|
||||
def _deterministic_offset(seed_text: str, radius: int) -> tuple[int, int]:
|
||||
"""텍스트 기반 결정적 난수 오프셋 생성"""
|
||||
digest = hashlib.md5(seed_text.encode("utf-8")).digest()
|
||||
x_offset = (digest[0] % (radius * 2 + 1)) - radius
|
||||
y_offset = (digest[1] % (radius * 2 + 1)) - radius
|
||||
return x_offset, y_offset
|
||||
|
||||
|
||||
def _apply_hit_ball_variation(
|
||||
result_text: str, result_type: str, zone: str, x: int, y: int,
|
||||
) -> tuple[int, int]:
|
||||
"""타구 텍스트에 따른 좌표 변화율(오프셋) 적용"""
|
||||
dir_x, dir_y = extract_direction_offsets(result_text)
|
||||
|
||||
if "파울플라이" in result_text or "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
|
||||
is_left = any(token in result_text for token in ("좌", "3루", "유격"))
|
||||
foul_x, foul_y = get_foul_fly_coordinates("left" if is_left else "right")
|
||||
x_offset, y_offset = _deterministic_offset(result_text, 2)
|
||||
return (
|
||||
max(0, min(100, foul_x + x_offset)),
|
||||
max(50, min(100, foul_y + y_offset)),
|
||||
)
|
||||
|
||||
if result_type == "home_run":
|
||||
x_offset, y_offset = _deterministic_offset(result_text, 2)
|
||||
return (
|
||||
max(15, min(85, x + x_offset)),
|
||||
max(12, min(22, y + y_offset)),
|
||||
)
|
||||
|
||||
if is_infield_zone(zone):
|
||||
base_shift = 3
|
||||
random_radius = 2 if result_type == "out" else 3
|
||||
else:
|
||||
base_shift = 12
|
||||
random_radius = 5 if result_type == "out" else 7
|
||||
|
||||
x_offset, y_offset = _deterministic_offset(result_text, random_radius)
|
||||
return (
|
||||
max(10, min(90, x + dir_x * base_shift + x_offset)),
|
||||
max(18, min(96, y + dir_y * base_shift + y_offset)),
|
||||
)
|
||||
|
||||
|
||||
def build_hit_ball_payload(page: Page, result_text: str) -> dict[str, str]:
|
||||
"""타구 좌표 및 거리 페이로드 생성"""
|
||||
zone = infer_field_zone(result_text)
|
||||
x, y = get_zone_coordinates(zone)
|
||||
meter_per_px_text = page.locator("#dat_meterPerPx").input_value() or "0"
|
||||
try:
|
||||
meter_per_px = float(meter_per_px_text)
|
||||
except ValueError:
|
||||
meter_per_px = 0.0
|
||||
|
||||
result_type = "home_run" if "홈런" in result_text else ("out" if "아웃" in result_text or "희생" in result_text else "safe")
|
||||
hit_ball_type_label = infer_hit_ball_type(result_text)
|
||||
x, y = _apply_hit_ball_variation(result_text, result_type, zone, x, y)
|
||||
|
||||
px_x = math.floor(650 * x / 100)
|
||||
px_y = math.floor(621 * y / 100)
|
||||
|
||||
distance = 0
|
||||
if meter_per_px:
|
||||
distance = math.floor(math.sqrt((50 - x) ** 2 + (95 - y) ** 2) * math.hypot(6.5, 6.21) * meter_per_px / 100)
|
||||
|
||||
return {
|
||||
"type": get_hit_ball_type_code(hit_ball_type_label),
|
||||
"label": hit_ball_type_label,
|
||||
"x": str(px_x),
|
||||
"y": str(px_y),
|
||||
"xy": f"{x},{y}",
|
||||
"distance": str(distance),
|
||||
}
|
||||
|
||||
|
||||
def set_hit_ball_and_defense(page: Page, event: dict[str, Any]) -> bool:
|
||||
"""구장 팝업이 열렸을 때 타구 좌표 지정 및 수비 결과 입력"""
|
||||
if not is_ball_in_play_event(event):
|
||||
return False
|
||||
|
||||
result_text = ((event.get("result") or {}).get("text") or "").strip()
|
||||
if not result_text:
|
||||
return False
|
||||
|
||||
# 팝업 가시성 대기
|
||||
try:
|
||||
page.wait_for_selector("#div_stadium_image", state="visible", timeout=2000)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 타구 좌표 계산 및 입력
|
||||
payload = build_hit_ball_payload(page, result_text)
|
||||
page.evaluate(
|
||||
"""(payload) => {
|
||||
const mapImg = document.getElementById('mapImg');
|
||||
if (!mapImg) return;
|
||||
|
||||
document.getElementById('dat_evt_hit_type').value = payload.type;
|
||||
const dropDown = document.querySelector("#div_hit_type button.dropdown-toggle");
|
||||
if (dropDown) {
|
||||
dropDown.innerHTML = payload.label + ' <span class="caret"></span>';
|
||||
}
|
||||
|
||||
document.getElementById('dat_hit_x').value = payload.x;
|
||||
document.getElementById('dat_hit_y').value = payload.y;
|
||||
document.getElementById('dat_hit_xy').value = payload.xy;
|
||||
document.getElementById('dat_hit_distance').value = payload.distance;
|
||||
document.getElementById('distance').value = payload.distance;
|
||||
|
||||
const mark = document.getElementById('map_mark');
|
||||
if (mark) {
|
||||
mark.style.display = 'block';
|
||||
mark.style.left = payload.x + 'px';
|
||||
mark.style.top = payload.y + 'px';
|
||||
}
|
||||
}""",
|
||||
payload,
|
||||
)
|
||||
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 타구 결과에 따른 수비 팝업/입력 처리
|
||||
result_type = (event.get("result") or {}).get("type") or ""
|
||||
|
||||
if result_type in {"single_error_advance", "double_error_advance", "triple_error_advance"}:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"reach_on_error"}:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"single_runner_out", "double_runner_out", "triple_runner_out"}:
|
||||
fill_runner_out_defense(page, result_text)
|
||||
elif "병살" in result_text:
|
||||
from core.field_calculator import build_double_play_first_sequence
|
||||
seq = build_double_play_first_sequence(event)
|
||||
if seq:
|
||||
click_defense_sequence_in_popup(page, seq)
|
||||
btn = get_last_visible_locator(page, "#btnNext")
|
||||
if btn:
|
||||
btn.click()
|
||||
page.wait_for_timeout(100)
|
||||
elif "실책" in result_text:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"out", "double_play"} and "삼진" not in result_text:
|
||||
from core.field_calculator import extract_defense_sequence
|
||||
seq = extract_defense_sequence(result_text)
|
||||
if seq:
|
||||
click_defense_sequence_in_popup(page, seq, complete_button_selector="#btnAdd")
|
||||
|
||||
# 홈런일 경우 입력 완료 버튼 직접 클릭
|
||||
if result_type == "home_run":
|
||||
try:
|
||||
page.locator("#btnInputComplete").click(timeout=1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def set_batter_result_type(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
|
||||
"""타격 결과 종류(1루타, 수비실책 등)만 세팅"""
|
||||
if not result:
|
||||
return
|
||||
label = infer_batter_result_label(result, event)
|
||||
if not label:
|
||||
return
|
||||
|
||||
# 강제 세팅 (병살 등)
|
||||
if label == "병살-아웃":
|
||||
forced = page.evaluate(
|
||||
"""(eventName) => {
|
||||
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
|
||||
for (const node of nodes) {
|
||||
const name = (node.getAttribute('eventName') || '').trim();
|
||||
if (name === eventName) {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
label,
|
||||
)
|
||||
if forced:
|
||||
page.wait_for_timeout(120)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
return
|
||||
|
||||
# JS 강제 이벤트 발생
|
||||
marker = page.evaluate(
|
||||
"""(eventName) => {
|
||||
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) {
|
||||
continue;
|
||||
}
|
||||
if ((node.getAttribute('eventName') || '') === eventName) {
|
||||
const marker = `codex-batter-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
label,
|
||||
)
|
||||
if marker:
|
||||
candidate = page.locator(f"[data-codex-marker='{marker}']")
|
||||
for _ in range(3):
|
||||
try:
|
||||
candidate.click(force=True)
|
||||
except Exception:
|
||||
candidate.evaluate(
|
||||
"""(node) => {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"""
|
||||
)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
return
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
# 폴백
|
||||
for _ in range(3):
|
||||
click_radio_by_label(page, "evt_batter", label)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
|
||||
def set_batter_advancement(page: Page, result: dict[str, Any] | None) -> None:
|
||||
"""타자의 최종 루(1루, 2루 등)와 주루가산 세팅"""
|
||||
if not result:
|
||||
return
|
||||
|
||||
to_base = result.get("toBase")
|
||||
|
||||
# 기본 진루
|
||||
if to_base is None:
|
||||
r_type = result.get("type")
|
||||
if r_type in {"single", "bunt_hit", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "hit_by_pitch", "walk", "intentional_walk"}:
|
||||
to_base = 1
|
||||
elif r_type == "double":
|
||||
to_base = 2
|
||||
elif r_type == "triple":
|
||||
to_base = 3
|
||||
elif r_type == "home_run":
|
||||
to_base = 4
|
||||
|
||||
if to_base is not None:
|
||||
try:
|
||||
selector = f"input[name='dat_evt_batter_advance'][value='{to_base}']"
|
||||
locator = get_last_visible_enabled_locator(page, selector)
|
||||
if locator is not None:
|
||||
locator.check(force=True)
|
||||
else:
|
||||
fallback = page.locator(selector)
|
||||
if fallback.count() > 0:
|
||||
fallback.first.check(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 주루가산 (Extra Advance)
|
||||
try:
|
||||
extra_advance = result.get("extra_advance")
|
||||
if extra_advance is not None and extra_advance > 0:
|
||||
locator = get_last_visible_enabled_locator(page, "#batterRunningAdd")
|
||||
if locator is not None:
|
||||
locator.select_option(value=str(extra_advance))
|
||||
else:
|
||||
fallback = page.locator("#batterRunningAdd")
|
||||
if fallback.count() > 0:
|
||||
fallback.first.select_option(value=str(extra_advance))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_batter_result(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
|
||||
"""타격 결과와 진루/가산 세팅"""
|
||||
set_batter_result_type(page, result, event)
|
||||
set_batter_advancement(page, result)
|
||||
Reference in New Issue
Block a user