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

271
automation/runner_input.py Normal file
View File

@@ -0,0 +1,271 @@
"""
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