272 lines
9.8 KiB
Python
272 lines
9.8 KiB
Python
"""
|
|
automation/runner_input.py — 주루 입력
|
|
|
|
주루 이벤트(진루, 도루, 견제, 실책 진루 등)를 관리자 사이트에 입력합니다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from time import time, sleep
|
|
from typing import Any
|
|
|
|
from playwright.sync_api import Page
|
|
|
|
from core.field_calculator import is_double_play_result, extract_error_position
|
|
from core.runner_classifier import infer_runner_action_label
|
|
from automation.page_helpers import (
|
|
get_last_visible_enabled_locator,
|
|
set_radio_by_label,
|
|
get_checked_event_name,
|
|
)
|
|
from automation.defense_popup import (
|
|
fill_error_defense_popup,
|
|
fill_runner_out_defense,
|
|
)
|
|
|
|
|
|
def _split_complex_runner_event(
|
|
runner_event: dict[str, Any],
|
|
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
"""복합 주루 이벤트(예: 1루주자 2루까지 진루 / 홈까지 들어오다 아웃) 분할"""
|
|
text = runner_event.get("text") or ""
|
|
|
|
if "/" not in text or ("실책" not in text and "아웃" not in text):
|
|
return runner_event, None
|
|
|
|
parts = [p.strip() for p in text.split("/") if p.strip()]
|
|
if len(parts) < 2:
|
|
return runner_event, None
|
|
|
|
def extract_base(t: str) -> int | None:
|
|
m = re.search(r"([123])루", t)
|
|
return int(m.group(1)) if m else None
|
|
|
|
# 1차 이벤트
|
|
primary = dict(runner_event)
|
|
primary["text"] = parts[0]
|
|
intermediate_to = extract_base(parts[0])
|
|
if intermediate_to:
|
|
primary["toBase"] = intermediate_to
|
|
|
|
# 2차 이벤트
|
|
secondary = dict(runner_event)
|
|
secondary["fromBase"] = primary.get("toBase")
|
|
secondary["text"] = parts[1]
|
|
|
|
if "실책" in parts[1]:
|
|
secondary["type"] = "error_advance"
|
|
elif "태그" in parts[1]:
|
|
secondary["type"] = "tag_out"
|
|
elif "포스" in parts[1]:
|
|
secondary["type"] = "force_out"
|
|
else:
|
|
secondary["type"] = "out"
|
|
|
|
return primary, secondary
|
|
|
|
|
|
def _open_runner_area(page: Page, from_base: int, area_type: int) -> None:
|
|
"""주루 영역(1: 진루, 2: 액션) 열기"""
|
|
function_name = f"changRunnerArea{from_base}"
|
|
page.evaluate(
|
|
"""({ functionName, areaType }) => {
|
|
const fn = window[functionName];
|
|
if (typeof fn === 'function') {
|
|
fn(areaType);
|
|
}
|
|
}""",
|
|
{"functionName": function_name, "areaType": area_type},
|
|
)
|
|
|
|
deadline = time() + 2
|
|
radio_name = f"evt_runner_{from_base}"
|
|
advance_name = f"dat_evt_runner_{from_base}_advance"
|
|
|
|
while time() < deadline:
|
|
ready = page.evaluate(
|
|
"""({ radioName, advanceName }) => {
|
|
const hasEnabledVisibleRadio = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)].some((node) => {
|
|
const rect = node.getBoundingClientRect();
|
|
const style = window.getComputedStyle(node);
|
|
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
|
|
});
|
|
const hasEnabledVisibleAdvance = [...document.querySelectorAll(`input[type=radio][name='${advanceName}']`)].some((node) => {
|
|
const rect = node.getBoundingClientRect();
|
|
const style = window.getComputedStyle(node);
|
|
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
|
|
});
|
|
return hasEnabledVisibleRadio || hasEnabledVisibleAdvance;
|
|
}""",
|
|
{"radioName": radio_name, "advanceName": advance_name},
|
|
)
|
|
if ready:
|
|
return
|
|
sleep(0.1)
|
|
|
|
|
|
def _set_runner_action(page: Page, from_base: int, label: str) -> None:
|
|
"""주자 액션(일반진루, 도루성공 등) 라디오 버튼 세팅"""
|
|
radio_name = f"evt_runner_{from_base}"
|
|
locator = page.evaluate(
|
|
r"""({ radioName, eventName }) => {
|
|
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)];
|
|
// 1단계: eventName 속성으로 매칭
|
|
for (const node of nodes) {
|
|
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-runner-${Math.random().toString(36).slice(2)}`;
|
|
node.setAttribute('data-codex-marker', marker);
|
|
return marker;
|
|
}
|
|
}
|
|
// 2단계: 텍스트로 부분 매칭
|
|
for (const node of nodes) {
|
|
let text = '';
|
|
let p = node.parentElement;
|
|
if (p) text = p.textContent.trim();
|
|
if (!text && node.nextSibling) text = node.nextSibling.textContent || '';
|
|
|
|
if (eventName && text.replace(/\s/g, '').includes(eventName.replace(/\s/g, ''))) {
|
|
const marker = `codex-runner-${Math.random().toString(36).slice(2)}`;
|
|
node.setAttribute('data-codex-marker', marker);
|
|
return marker;
|
|
}
|
|
}
|
|
return null;
|
|
}""",
|
|
{"radioName": radio_name, "eventName": label},
|
|
)
|
|
|
|
if locator:
|
|
candidate = page.locator(f"[data-codex-marker='{locator}']")
|
|
for _ in range(5):
|
|
try:
|
|
candidate.click(force=True, timeout=500)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
candidate.evaluate(
|
|
"""(node) => {
|
|
node.disabled = false;
|
|
node.checked = true;
|
|
node.dispatchEvent(new Event('click', { bubbles: true }));
|
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}"""
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
page.wait_for_timeout(100)
|
|
if get_checked_event_name(page, radio_name) == label:
|
|
return
|
|
else:
|
|
for _ in range(5):
|
|
set_radio_by_label(page, radio_name, label)
|
|
page.wait_for_timeout(50)
|
|
if get_checked_event_name(page, radio_name) == label:
|
|
return
|
|
|
|
|
|
def _set_runner_advance(page: Page, from_base: int, to_base: int | None) -> None:
|
|
"""주자 최종 목적지 루 세팅"""
|
|
if to_base is None:
|
|
return
|
|
selector = f"input[name='dat_evt_runner_{from_base}_advance'][value='{to_base}']"
|
|
deadline = time() + 3
|
|
locator = None
|
|
|
|
while time() < deadline:
|
|
locator = get_last_visible_enabled_locator(page, selector)
|
|
if locator is not None:
|
|
break
|
|
sleep(0.1)
|
|
|
|
if locator is None:
|
|
fallback = page.locator(selector)
|
|
if fallback.count():
|
|
locator = fallback.last
|
|
else:
|
|
return # timeout
|
|
|
|
try:
|
|
locator.click(force=True)
|
|
except Exception:
|
|
locator.evaluate(
|
|
"""(node) => {
|
|
node.disabled = false;
|
|
node.checked = true;
|
|
node.dispatchEvent(new Event('click', { bubbles: true }));
|
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}"""
|
|
)
|
|
|
|
|
|
def set_runner_events(
|
|
page: Page, event: dict[str, Any], runner_events: list[dict[str, Any]] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""모든 주루 이벤트를 처리하고 지연 처리할 이벤트(late_events) 반환"""
|
|
if runner_events is None:
|
|
runner_events = (event.get("runnerEvents") or []).copy()
|
|
|
|
late_events = []
|
|
filtered_events = []
|
|
for re_item in runner_events:
|
|
primary, secondary = _split_complex_runner_event(re_item)
|
|
filtered_events.append(primary)
|
|
if secondary:
|
|
late_events.append(secondary)
|
|
|
|
for runner_event in filtered_events:
|
|
from_base = runner_event.get("fromBase")
|
|
if from_base not in {1, 2, 3}:
|
|
continue
|
|
|
|
label = infer_runner_action_label(event, runner_event)
|
|
if not label:
|
|
continue
|
|
|
|
if any(k in label for k in ["도루", "견제", "폭투", "포일", "아웃"]):
|
|
area_type = 2
|
|
else:
|
|
area_type = 1
|
|
|
|
if any(k in label for k in ["일반 진루", "볼넷 진루", "수비 실책"]):
|
|
area_type = 1
|
|
|
|
_open_runner_area(page, from_base, area_type)
|
|
_set_runner_action(page, from_base, label)
|
|
|
|
runner_text = runner_event.get("text") or ""
|
|
is_error_related_label = (
|
|
(label and "실책" in label)
|
|
or label in {"견제 에러", "수비 실책", "도루성공&실책"}
|
|
or "실책" in runner_text
|
|
)
|
|
if is_error_related_label:
|
|
if extract_error_position(runner_text):
|
|
fill_error_defense_popup(page, runner_text)
|
|
|
|
if label in {"태그아웃", "도루시도 아웃", "포스아웃"}:
|
|
fill_runner_out_defense(page, runner_text)
|
|
|
|
page.wait_for_timeout(150)
|
|
_set_runner_advance(page, from_base, runner_event.get("toBase"))
|
|
|
|
try:
|
|
extra_advance = runner_event.get("extra_advance")
|
|
if extra_advance and extra_advance > 0:
|
|
locator = get_last_visible_enabled_locator(page, f"select[name='runner{from_base}_running_add']")
|
|
if locator is not None:
|
|
locator.select_option(value=str(extra_advance))
|
|
except Exception:
|
|
pass
|
|
|
|
page.wait_for_timeout(150)
|
|
|
|
return late_events
|