""" automation/page_helpers.py — 공통 Playwright 유틸리티 라디오 버튼, select 박스, 가시성 판별 등 사이트 조작의 기초 함수. 모든 automation 모듈이 이 모듈에 의존합니다. """ from __future__ import annotations from time import sleep from typing import Any from playwright.sync_api import Page # ────────────────────────────────────────────── # 라디오 버튼 조작 # ────────────────────────────────────────────── def get_radio_map(page: Page, name: str) -> dict[str, str]: """라디오 그룹의 eventName → value 맵 반환""" return page.evaluate( """(name) => { const nodes = [...document.querySelectorAll(`input[type=radio][name='${name}']`)]; const map = {}; for (const node of nodes) { const eventName = (node.getAttribute('eventName') || '').trim(); if (eventName) { map[eventName] = node.value; } } return map; }""", name, ) def get_checked_event_name(page: Page, radio_name: str) -> str: """현재 체크된 라디오의 eventName 반환""" return page.evaluate( """(radioName) => { const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']:checked`)]; for (const node of nodes) { const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) { return node.getAttribute('eventName') || ''; } } return nodes.length > 0 ? (nodes[0].getAttribute('eventName') || '') : ''; }""", radio_name, ) def set_radio_by_label(page: Page, radio_name: str, label: str) -> None: """eventName이 label과 일치하는 라디오 클릭""" radios = page.locator(f"input[type=radio][name='{radio_name}']").all() target_radio = None # 정확히 일치하는 라벨 우선 for rb in radios: if rb.get_attribute("eventname") == label: target_radio = rb break # 포함 관계로 탐색 if not target_radio: for rb in radios: if label in (rb.get_attribute("eventname") or ""): target_radio = rb break if target_radio: target_radio.click(force=True) def click_radio_by_label(page: Page, radio_name: str, label: str) -> None: """라디오 버튼을 JS로 강제 클릭 (disabled 상태도 처리)""" page.evaluate( """({ radioName, label }) => { const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)]; for (const node of nodes) { const eventName = (node.getAttribute('eventName') || '').trim(); if (eventName === label) { node.disabled = false; node.checked = true; node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); node.dispatchEvent(new Event('change', { bubbles: true })); return; } } }""", {"radioName": radio_name, "label": label}, ) def find_visible_radio_by_label(page: Page, radio_name: str, label: str): """가시적이고 활성화된 라디오를 찾아서 Locator 반환""" marker = page.evaluate( """({ radioName, expectedLabel }) => { const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)]; 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; } const name = (node.getAttribute('eventName') || '').trim(); if (name === expectedLabel) { const marker = `codex-radio-${Math.random().toString(36).slice(2)}`; node.setAttribute('data-codex-marker', marker); return marker; } } return null; }""", {"radioName": radio_name, "expectedLabel": label}, ) if marker: return page.locator(f"[data-codex-marker='{marker}']") return None # ────────────────────────────────────────────── # 가시성 유틸 # ────────────────────────────────────────────── def get_last_visible_locator(page: Page, selector: str): """selector 중 마지막으로 보이는 요소의 Locator 반환""" marker = page.evaluate( """(selector) => { const nodes = [...document.querySelectorAll(selector)]; 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 > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden') { const marker = `codex-visible-${Math.random().toString(36).slice(2)}`; node.setAttribute('data-codex-marker', marker); return marker; } } return null; }""", selector, ) if marker: return page.locator(f"[data-codex-marker='{marker}']") return None def get_last_visible_enabled_locator(page: Page, selector: str): """selector 중 마지막으로 보이고 활성화된 요소의 Locator 반환""" marker = page.evaluate( """(selector) => { const nodes = [...document.querySelectorAll(selector)]; 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 > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) { const marker = `codex-enabled-${Math.random().toString(36).slice(2)}`; node.setAttribute('data-codex-marker', marker); return marker; } } return null; }""", selector, ) if marker: return page.locator(f"[data-codex-marker='{marker}']") return None # ────────────────────────────────────────────── # select 박스 조작 # ────────────────────────────────────────────── def set_select_by_partial_text(page: Page, selector: str, partial_text: str) -> None: """텍스트 부분 일치로 select option 선택""" if not partial_text: return page.wait_for_selector(selector, timeout=3000) options = page.locator(f"{selector} option").all_text_contents() target = partial_text.strip() for opt in options: if opt.strip() == target: page.select_option(selector, label=opt) return target_clean = target.replace(" ", "").replace("/", ",").replace("-", ",") for opt in options: opt_clean = opt.strip().replace(" ", "").replace("/", ",").replace("-", ",") if target_clean in opt_clean or opt_clean in target_clean: page.select_option(selector, label=opt) return print(f"DEBUG: '{selector}'에서 '{partial_text}'와 일치하는 옵션을 찾지 못함.") def set_select_by_text_or_value(page: Page, selector: str, desired: str) -> None: """label 또는 value로 select option 선택""" locator = page.locator(selector) try: locator.select_option(label=desired) return except Exception: pass try: locator.select_option(value=desired) return except Exception: pass locator.select_option(index=0) # ────────────────────────────────────────────── # 디버그 오버레이 & 제어 # ────────────────────────────────────────────── def show_debug_overlay(page: Page, lines: list[str]) -> None: """페이지에 디버그 오버레이 표시""" page.evaluate( """(lines) => { let box = document.querySelector('#codex-debug-overlay'); if (!box) { window.codexControl = { paused: false, proceed: 0 }; box = document.createElement('div'); box.id = 'codex-debug-overlay'; box.style.cssText = 'position:fixed;top:12px;right:12px;z-index:999999;background:rgba(0,0,0,0.82);color:#fff;padding:10px 12px;border-radius:8px;font-size:14px;line-height:1.5;max-width:360px;white-space:pre-wrap;box-shadow:0 4px 16px rgba(0,0,0,0.35)'; const controls = document.createElement('div'); controls.style.cssText = 'margin-bottom:8px;display:flex;gap:8px'; const pauseBtn = document.createElement('button'); pauseBtn.id = 'codex-pause-btn'; pauseBtn.textContent = '일시정지'; pauseBtn.onclick = () => { window.codexControl.paused = !window.codexControl.paused; pauseBtn.textContent = window.codexControl.paused ? '재개' : '일시정지'; }; const nextBtn = document.createElement('button'); nextBtn.id = 'codex-next-btn'; nextBtn.textContent = '다음'; nextBtn.onclick = () => { window.codexControl.proceed += 1; }; controls.appendChild(pauseBtn); controls.appendChild(nextBtn); const body = document.createElement('div'); body.id = 'codex-debug-body'; box.appendChild(controls); box.appendChild(body); document.body.appendChild(box); } const body = document.querySelector('#codex-debug-body'); if (body) { body.textContent = lines.join('\\n'); } }""", lines, ) def wait_for_operator_control(page: Page) -> None: """일시정지/다음 버튼 대기""" state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }") last_proceed = state.get("proceed", 0) while True: state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }") if not state.get("paused"): return if state.get("proceed", 0) > last_proceed: page.evaluate( "(prev) => { if (window.codexControl && window.codexControl.proceed > prev) { window.codexControl.proceed = prev; } }", last_proceed, ) return sleep(0.1) # ────────────────────────────────────────────── # 카운트 관리 # ────────────────────────────────────────────── def advance_count(balls: int, strikes: int, pitch_result: str) -> tuple[int, int]: """투구 결과에 따른 카운트 갱신""" if pitch_result in ("B",): return balls + 1, strikes if pitch_result in ("T", "S", "BS"): return balls, strikes + 1 if pitch_result in ("F", "BF"): if strikes < 2: return balls, strikes + 1 return balls, strikes def get_checked_batter_defense_type(page: Page) -> str: """현재 선택된 타격 결과의 수비 유형 반환""" return page.evaluate( """() => { const checked = document.querySelector("input[type=radio][name='evt_batter']:checked"); if (!checked) return ''; return checked.getAttribute('defenseType') || ''; }""" ) def get_last_history_text(page: Page) -> str: """사이트 내역(historyView)의 마지막 항목 텍스트 추출""" try: return ( page.evaluate( """() => { const nodes = document.querySelectorAll("div[name='historyView']"); const lastNode = nodes[nodes.length - 1]; return lastNode ? lastNode.textContent.trim() : ''; }""" ) or "" ).strip() except Exception: return "" def get_history_count(page: Page) -> int: """기록 영역에 추가된 이벤트(historyView)의 총 개수 반환""" try: return int( page.evaluate( """() => document.querySelectorAll("div[name='historyView']").length""" ) ) except Exception: return 0