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

322
automation/batter_input.py Normal file
View 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)