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

299
automation/lineup_input.py Normal file
View File

@@ -0,0 +1,299 @@
"""
automation/lineup_input.py — 라인업 및 선수 교체 입력
관리자 사이트의 홈/원정 라인업 조작, 선수명 및 포지션 선택,
교체 이벤트 처리를 담당합니다.
"""
from __future__ import annotations
import re
from typing import Any
from playwright.sync_api import Page
from core.config_loader import position_to_defense_no
from core.change_parser import normalize_change_event
def normalize_lineup_text(text: str) -> str:
"""라인업 선수명 정규화 (괄호, 번호 등 제거하고 이름만 추출)"""
text = (text or "").strip()
text = text.replace("*", "")
text = re.sub(r"\[\d+(?:번)?\]", "", text)
text = re.sub(r"\s*\(.*?\)\s*", "", text)
text = "".join(re.findall(r"[가-힣A-Za-z]+", text))
return text.strip()
def get_lineup_state(page: Page) -> dict[str, Any]:
"""현재 사이트에 입력된 라인업 상태(홈/원정 0~9번 행) 추출"""
return page.evaluate(
"""() => {
const buildSide = (side) => {
const rows = [];
for (let idx = 0; idx <= 9; idx += 1) {
const player = document.querySelector(`#${side}_player_id_${idx}`);
const defense = document.querySelector(`#${side}_defense_no_${idx}`);
const orgPlayer = document.querySelector(`#org_${side}_player_id_${idx}`);
const orgDefense = document.querySelector(`#org_${side}_defense_no_${idx}`);
if (!player && !defense) continue;
rows.push({
idx,
playerText: player ? player.options[player.selectedIndex]?.text || '' : '',
playerValue: player ? player.value : '',
defenseValue: defense ? defense.value : '',
orgPlayerValue: orgPlayer ? orgPlayer.value : '',
orgDefenseValue: orgDefense ? orgDefense.value : '',
});
}
return rows;
};
return { home: buildSide('home'), away: buildSide('away') };
}"""
)
def detect_change_side(
half_inning: dict[str, Any], change_event: dict[str, Any], lineup_state: dict[str, Any],
) -> str:
"""교체가 일어난 팀(home/away) 추론"""
actor_name = normalize_lineup_text(
change_event.get("actor_name") or change_event.get("player_name") or ""
)
offense_side = "away" if half_inning.get("half") == "top" else "home"
defense_side = "home" if offense_side == "away" else "away"
matched_sides: list[str] = []
for side in ("home", "away"):
for row in lineup_state.get(side, []):
if normalize_lineup_text(row.get("playerText") or "") == actor_name:
matched_sides.append(side)
break
if len(matched_sides) == 1:
return matched_sides[0]
actor_role = change_event.get("actor_role")
if actor_role in {"batter", "대타", "대주자", "1루주자", "2루주자", "3루주자", "주자"}:
return offense_side
return defense_side
def find_change_row(side_rows: list[dict[str, Any]], change_event: dict[str, Any]) -> int | None:
"""교체 대상 선수가 속한 라인업 행 인덱스 검색"""
if change_event.get("bat_order") is not None:
return int(change_event["bat_order"])
actor_name_raw = (
change_event.get("actor_name")
or change_event.get("player_name")
or change_event.get("out_player")
or ""
)
actor_name = normalize_lineup_text(actor_name_raw)
# 1단계: 완전 일치
for row in side_rows:
if normalize_lineup_text(row.get("playerText") or "") == actor_name:
return int(row["idx"])
# 2단계: 부분 일치
for row in side_rows:
player_text = normalize_lineup_text(row.get("playerText") or "")
if actor_name and (actor_name in player_text or player_text in actor_name):
return int(row["idx"])
# 3단계: 역할(defenseValue) 일치
actor_role = change_event.get("actor_role")
pos_def_map = position_to_defense_no()
if actor_role in pos_def_map:
defense_no = pos_def_map[actor_role]
for row in side_rows:
if str(row.get("defenseValue") or "") == defense_no:
return int(row["idx"])
# 4단계: 이름 혼재 경우 (예: "1루주자 문보경")
if " " in actor_name_raw:
potential_name = normalize_lineup_text(actor_name_raw.split()[-1])
for row in side_rows:
if normalize_lineup_text(row.get("playerText") or "") == potential_name:
return int(row["idx"])
return None
def find_pitcher_row(side_rows: list[dict[str, Any]]) -> int | None:
"""투수가 위치한 행(일반적으로 0번) 찾기"""
pos_def_map = position_to_defense_no()
pitcher_no = pos_def_map.get("투수")
for row in side_rows:
if str(row.get("defenseValue") or "") == pitcher_no:
return int(row["idx"])
return None
def select_lineup_player(page: Page, side: str, row_idx: int, player_name: str) -> None:
"""라인업 select 상자에서 선수명으로 선택"""
select_id = f"#{side}_player_id_{row_idx}"
options = page.locator(f"{select_id} option").evaluate_all(
"""(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))"""
)
target_value = None
normalized_target = normalize_lineup_text(player_name)
for option in options:
if normalize_lineup_text(option["text"]) == normalized_target:
target_value = option["value"]
break
if not target_value:
raise ValueError(f"{side} {row_idx}번 행에서 선수 '{player_name}' 옵션을 찾지 못했습니다.")
page.locator(select_id).select_option(value=target_value)
def set_lineup_defense(page: Page, side: str, row_idx: int, position: str | None) -> None:
"""라인업 수비 포지션 세팅"""
if not position:
return
defense_no = position_to_defense_no().get(position)
if not defense_no:
return
page.locator(f"#{side}_defense_no_{row_idx}").select_option(value=defense_no)
def get_current_lineup_selection(page: Page, side: str, row_idx: int) -> tuple[str, str]:
"""해당 행의 현재 선택된 선수와 수비 번호 값 반환"""
player_value = page.locator(f"#{side}_player_id_{row_idx}").input_value()
defense_value = page.locator(f"#{side}_defense_no_{row_idx}").input_value()
return player_value, defense_value
def get_target_player_value(page: Page, side: str, row_idx: int, player_name: str | None) -> str | None:
if not player_name:
return None
select_id = f"#{side}_player_id_{row_idx}"
options = page.locator(f"{select_id} option").evaluate_all(
"""(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))"""
)
normalized_target = normalize_lineup_text(player_name)
for option in options:
if normalize_lineup_text(option["text"]) == normalized_target:
return option["value"]
return None
def get_target_defense_value(position: str | None) -> str | None:
if not position:
return None
return position_to_defense_no().get(position)
def apply_change_event(
page: Page,
half_inning: dict[str, Any],
change_event: dict[str, Any],
change_cache: dict[str, tuple[str, int]],
) -> None:
"""선수 교체 이벤트를 라인업에 반영하고 저장"""
change_event = normalize_change_event(change_event)
actor_name_raw = change_event.get("actor_name") or change_event.get("player_name") or change_event.get("out_player") or ""
cache_key = normalize_lineup_text(actor_name_raw)
actor_name = normalize_lineup_text(actor_name_raw)
lineup_state = get_lineup_state(page)
cached = change_cache.get(cache_key)
side = cached[0] if cached else detect_change_side(half_inning, change_event, lineup_state)
side_rows = lineup_state.get(side, [])
row_idx = find_change_row(side_rows, change_event)
if row_idx is None and cached and cached[0] == side:
row_idx = cached[1]
if row_idx is None:
all_players = [normalize_lineup_text(r.get("playerText", "")) for r in side_rows]
raise ValueError(
f"{side} 교체 행을 찾지 못했습니다 "
f"(Target: {actor_name}, Candidates: {all_players}): {change_event.get('text')}"
)
# 확인창 무시
page.evaluate("""() => { window.alert = () => {}; window.confirm = () => true; }""")
def trigger_lineup_save(idx: int):
home_away_gb = 2 if side == "home" else 1
page.evaluate(
"""({ batterNo, homeAwayGb }) => {
if (typeof window.f_lineup === 'function') {
window.f_lineup(batterNo, homeAwayGb);
}
}""",
{"batterNo": idx, "homeAwayGb": home_away_gb},
)
page.wait_for_timeout(200)
page.evaluate(
"""({ side, idx }) => {
const row = document.querySelector(`#${side}_player_id_${idx}`)?.parentElement?.parentElement;
if (row) {
const saveBtn = [...row.querySelectorAll("input[type=button], button, a")].find(el =>
el.value === 'V' || el.innerText.includes('V') || el.innerText.includes('저장') || el.id.includes('save')
);
if (saveBtn) {
saveBtn.click();
return true;
}
}
return false;
}""",
{"side": side, "idx": idx}
)
page.wait_for_timeout(200)
# 병합 교체 (예: 포수→지명타자 전환 + 새 투수 등판)
if change_event.get("change_type") == "merged_pitcher_substitution":
actor_player_name = change_event.get("player_name") or change_event.get("actor_name")
pitcher_in_player = change_event.get("pitcher_in_player") or change_event.get("in_player")
pitcher_row_idx = find_pitcher_row(side_rows)
if pitcher_row_idx is None or not pitcher_in_player:
raise ValueError(f"투수 교체 행을 찾지 못했습니다: {change_event.get('text')}")
set_lineup_defense(page, side, row_idx, "지명타자")
trigger_lineup_save(row_idx)
trigger_lineup_save(row_idx + 1)
select_lineup_player(page, side, pitcher_row_idx, pitcher_in_player)
set_lineup_defense(page, side, pitcher_row_idx, "투수")
trigger_lineup_save(pitcher_row_idx)
trigger_lineup_save(pitcher_row_idx + 1)
if actor_player_name:
change_cache[normalize_lineup_text(actor_player_name)] = (side, int(row_idx))
change_cache[normalize_lineup_text(pitcher_in_player)] = (side, int(pitcher_row_idx))
return
# 단순 선수 교체
if change_event.get("change_type") == "substitution":
in_player = change_event.get("in_player")
if not in_player:
return
select_lineup_player(page, side, row_idx, in_player)
set_lineup_defense(page, side, row_idx, change_event.get("to_position"))
trigger_lineup_save(row_idx)
trigger_lineup_save(row_idx + 1)
out_player = change_event.get("out_player")
if out_player:
change_cache[normalize_lineup_text(out_player)] = (side, int(row_idx))
change_cache[normalize_lineup_text(in_player)] = (side, int(row_idx))
return
# 단순 수비 위치 변경
if change_event.get("change_type") == "position_change":
set_lineup_defense(page, side, row_idx, change_event.get("to_position"))
trigger_lineup_save(row_idx)
trigger_lineup_save(row_idx + 1)
player_name = change_event.get("player_name")
if player_name:
change_cache[normalize_lineup_text(player_name)] = (side, int(row_idx))
return