""" 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