refactoring
This commit is contained in:
338
automation/page_helpers.py
Normal file
338
automation/page_helpers.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user