Files
baseball-automation/automation/page_helpers.py
2026-05-02 16:24:42 +09:00

339 lines
13 KiB
Python

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