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