Compare commits

2 Commits
main ... dev

Author SHA1 Message Date
3c6df12e70 Add refactoring.md 2026-05-02 16:36:13 +09:00
859c39fe0c refactoring 2026-05-02 16:24:42 +09:00
196 changed files with 6918 additions and 1 deletions

5
automation/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
automation/ — Playwright 기반 관리자 사이트 자동 입력 패키지
config/와 core/에 의존하며, 관리자 사이트의 UI 조작을 담당합니다.
"""

322
automation/batter_input.py Normal file
View File

@@ -0,0 +1,322 @@
"""
automation/batter_input.py — 타석 결과 입력
타석의 결과(타구 좌표, 타격 결과, 타자 진루)를 입력합니다.
"""
from __future__ import annotations
import math
import hashlib
from typing import Any
from playwright.sync_api import Page
from core.pitch_classifier import infer_batter_result_label, is_ball_in_play_event
from core.field_calculator import (
infer_hit_ball_type,
infer_field_zone,
get_hit_ball_type_code,
get_zone_coordinates,
get_foul_fly_coordinates,
extract_direction_offsets,
is_infield_zone,
)
from automation.page_helpers import (
click_radio_by_label,
get_checked_event_name,
get_last_visible_enabled_locator,
)
from automation.defense_popup import (
fill_error_defense_popup,
click_defense_sequence_in_popup,
fill_runner_out_defense,
)
def _deterministic_offset(seed_text: str, radius: int) -> tuple[int, int]:
"""텍스트 기반 결정적 난수 오프셋 생성"""
digest = hashlib.md5(seed_text.encode("utf-8")).digest()
x_offset = (digest[0] % (radius * 2 + 1)) - radius
y_offset = (digest[1] % (radius * 2 + 1)) - radius
return x_offset, y_offset
def _apply_hit_ball_variation(
result_text: str, result_type: str, zone: str, x: int, y: int,
) -> tuple[int, int]:
"""타구 텍스트에 따른 좌표 변화율(오프셋) 적용"""
dir_x, dir_y = extract_direction_offsets(result_text)
if "파울플라이" in result_text or "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
is_left = any(token in result_text for token in ("", "3루", "유격"))
foul_x, foul_y = get_foul_fly_coordinates("left" if is_left else "right")
x_offset, y_offset = _deterministic_offset(result_text, 2)
return (
max(0, min(100, foul_x + x_offset)),
max(50, min(100, foul_y + y_offset)),
)
if result_type == "home_run":
x_offset, y_offset = _deterministic_offset(result_text, 2)
return (
max(15, min(85, x + x_offset)),
max(12, min(22, y + y_offset)),
)
if is_infield_zone(zone):
base_shift = 3
random_radius = 2 if result_type == "out" else 3
else:
base_shift = 12
random_radius = 5 if result_type == "out" else 7
x_offset, y_offset = _deterministic_offset(result_text, random_radius)
return (
max(10, min(90, x + dir_x * base_shift + x_offset)),
max(18, min(96, y + dir_y * base_shift + y_offset)),
)
def build_hit_ball_payload(page: Page, result_text: str) -> dict[str, str]:
"""타구 좌표 및 거리 페이로드 생성"""
zone = infer_field_zone(result_text)
x, y = get_zone_coordinates(zone)
meter_per_px_text = page.locator("#dat_meterPerPx").input_value() or "0"
try:
meter_per_px = float(meter_per_px_text)
except ValueError:
meter_per_px = 0.0
result_type = "home_run" if "홈런" in result_text else ("out" if "아웃" in result_text or "희생" in result_text else "safe")
hit_ball_type_label = infer_hit_ball_type(result_text)
x, y = _apply_hit_ball_variation(result_text, result_type, zone, x, y)
px_x = math.floor(650 * x / 100)
px_y = math.floor(621 * y / 100)
distance = 0
if meter_per_px:
distance = math.floor(math.sqrt((50 - x) ** 2 + (95 - y) ** 2) * math.hypot(6.5, 6.21) * meter_per_px / 100)
return {
"type": get_hit_ball_type_code(hit_ball_type_label),
"label": hit_ball_type_label,
"x": str(px_x),
"y": str(px_y),
"xy": f"{x},{y}",
"distance": str(distance),
}
def set_hit_ball_and_defense(page: Page, event: dict[str, Any]) -> bool:
"""구장 팝업이 열렸을 때 타구 좌표 지정 및 수비 결과 입력"""
if not is_ball_in_play_event(event):
return False
result_text = ((event.get("result") or {}).get("text") or "").strip()
if not result_text:
return False
# 팝업 가시성 대기
try:
page.wait_for_selector("#div_stadium_image", state="visible", timeout=2000)
except Exception:
return False
# 타구 좌표 계산 및 입력
payload = build_hit_ball_payload(page, result_text)
page.evaluate(
"""(payload) => {
const mapImg = document.getElementById('mapImg');
if (!mapImg) return;
document.getElementById('dat_evt_hit_type').value = payload.type;
const dropDown = document.querySelector("#div_hit_type button.dropdown-toggle");
if (dropDown) {
dropDown.innerHTML = payload.label + ' <span class="caret"></span>';
}
document.getElementById('dat_hit_x').value = payload.x;
document.getElementById('dat_hit_y').value = payload.y;
document.getElementById('dat_hit_xy').value = payload.xy;
document.getElementById('dat_hit_distance').value = payload.distance;
document.getElementById('distance').value = payload.distance;
const mark = document.getElementById('map_mark');
if (mark) {
mark.style.display = 'block';
mark.style.left = payload.x + 'px';
mark.style.top = payload.y + 'px';
}
}""",
payload,
)
page.wait_for_timeout(300)
# 타구 결과에 따른 수비 팝업/입력 처리
result_type = (event.get("result") or {}).get("type") or ""
if result_type in {"single_error_advance", "double_error_advance", "triple_error_advance"}:
fill_error_defense_popup(page, result_text)
elif result_type in {"reach_on_error"}:
fill_error_defense_popup(page, result_text)
elif result_type in {"single_runner_out", "double_runner_out", "triple_runner_out"}:
fill_runner_out_defense(page, result_text)
elif "병살" in result_text:
from core.field_calculator import build_double_play_first_sequence
seq = build_double_play_first_sequence(event)
if seq:
click_defense_sequence_in_popup(page, seq)
btn = get_last_visible_locator(page, "#btnNext")
if btn:
btn.click()
page.wait_for_timeout(100)
elif "실책" in result_text:
fill_error_defense_popup(page, result_text)
elif result_type in {"out", "double_play"} and "삼진" not in result_text:
from core.field_calculator import extract_defense_sequence
seq = extract_defense_sequence(result_text)
if seq:
click_defense_sequence_in_popup(page, seq, complete_button_selector="#btnAdd")
# 홈런일 경우 입력 완료 버튼 직접 클릭
if result_type == "home_run":
try:
page.locator("#btnInputComplete").click(timeout=1000)
except Exception:
pass
return True
def set_batter_result_type(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
"""타격 결과 종류(1루타, 수비실책 등)만 세팅"""
if not result:
return
label = infer_batter_result_label(result, event)
if not label:
return
# 강제 세팅 (병살 등)
if label == "병살-아웃":
forced = page.evaluate(
"""(eventName) => {
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
for (const node of nodes) {
const name = (node.getAttribute('eventName') || '').trim();
if (name === eventName) {
node.disabled = false;
node.checked = true;
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
}
return false;
}""",
label,
)
if forced:
page.wait_for_timeout(120)
if get_checked_event_name(page, "evt_batter") == label:
return
# JS 강제 이벤트 발생
marker = page.evaluate(
"""(eventName) => {
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
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;
}
if ((node.getAttribute('eventName') || '') === eventName) {
const marker = `codex-batter-${Math.random().toString(36).slice(2)}`;
node.setAttribute('data-codex-marker', marker);
return marker;
}
}
return null;
}""",
label,
)
if marker:
candidate = page.locator(f"[data-codex-marker='{marker}']")
for _ in range(3):
try:
candidate.click(force=True)
except Exception:
candidate.evaluate(
"""(node) => {
node.disabled = false;
node.checked = true;
node.dispatchEvent(new Event('click', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}"""
)
if get_checked_event_name(page, "evt_batter") == label:
return
page.wait_for_timeout(100)
# 폴백
for _ in range(3):
click_radio_by_label(page, "evt_batter", label)
if get_checked_event_name(page, "evt_batter") == label:
break
page.wait_for_timeout(100)
def set_batter_advancement(page: Page, result: dict[str, Any] | None) -> None:
"""타자의 최종 루(1루, 2루 등)와 주루가산 세팅"""
if not result:
return
to_base = result.get("toBase")
# 기본 진루
if to_base is None:
r_type = result.get("type")
if r_type in {"single", "bunt_hit", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "hit_by_pitch", "walk", "intentional_walk"}:
to_base = 1
elif r_type == "double":
to_base = 2
elif r_type == "triple":
to_base = 3
elif r_type == "home_run":
to_base = 4
if to_base is not None:
try:
selector = f"input[name='dat_evt_batter_advance'][value='{to_base}']"
locator = get_last_visible_enabled_locator(page, selector)
if locator is not None:
locator.check(force=True)
else:
fallback = page.locator(selector)
if fallback.count() > 0:
fallback.first.check(force=True)
except Exception:
pass
# 주루가산 (Extra Advance)
try:
extra_advance = result.get("extra_advance")
if extra_advance is not None and extra_advance > 0:
locator = get_last_visible_enabled_locator(page, "#batterRunningAdd")
if locator is not None:
locator.select_option(value=str(extra_advance))
else:
fallback = page.locator("#batterRunningAdd")
if fallback.count() > 0:
fallback.first.select_option(value=str(extra_advance))
except Exception:
pass
def set_batter_result(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
"""타격 결과와 진루/가산 세팅"""
set_batter_result_type(page, result, event)
set_batter_advancement(page, result)

118
automation/defense_popup.py Normal file
View File

@@ -0,0 +1,118 @@
"""
automation/defense_popup.py — 수비 팝업 조작
수비 버튼 클릭, 수비 시퀀스 입력, 실책 수비 팝업 처리.
"""
from __future__ import annotations
from typing import Any
from playwright.sync_api import Page
from core.config_loader import defense_button_id_map, position_number_map
from core.field_calculator import (
extract_defense_sequence,
extract_error_position,
infer_error_position_fallback,
is_throwing_error,
)
from automation.page_helpers import get_last_visible_locator
def click_defense_button_robustly(page: Page, position: str, click_count: int = 1) -> bool:
"""수비 포지션 버튼을 안정적으로 클릭
ID 기반 → value 기반 → label 기반 순서로 시도
"""
btn_map = defense_button_id_map()
pos_num = position_number_map()
# 1) ID 기반
button_selector = btn_map.get(position)
if button_selector:
loc = page.locator(button_selector)
if loc.count() > 0:
for _ in range(click_count):
loc.click(force=True)
page.wait_for_timeout(60)
return True
# 2) value 기반
value = pos_num.get(position)
if value:
loc = page.locator(f"input[name='defenseNumberBtn'][value='{value}']")
if loc.count() > 0:
for _ in range(click_count):
loc.click(force=True)
page.wait_for_timeout(60)
return True
# 3) label 기반
all_buttons = page.locator("input[name='defenseNumberBtn']").all()
for btn in all_buttons:
label = (btn.get_attribute("id") or "").lower()
if position in label or label in position:
for _ in range(click_count):
btn.click(force=True)
page.wait_for_timeout(60)
return True
return False
def clear_defense_selections(page: Page) -> None:
"""수비 선택 초기화"""
page.evaluate(
"""() => {
document.querySelectorAll("input[name='defenseNumberBtn']").forEach(btn => {
btn.checked = false;
});
}"""
)
def click_defense_sequence_in_popup(
page: Page,
sequence: list[str],
complete_button_selector: str | None = None,
) -> None:
"""수비 시퀀스 순서대로 클릭 후 완료 버튼 클릭"""
for position in sequence:
click_defense_button_robustly(page, position)
page.wait_for_timeout(80)
if complete_button_selector:
btn = get_last_visible_locator(page, complete_button_selector)
if btn:
btn.click()
page.wait_for_timeout(120)
def fill_runner_out_defense(
page: Page, text: str, sequence_override: list[str] | None = None,
) -> None:
"""주루 아웃 수비 팝업 처리"""
page.wait_for_timeout(300)
sequence = sequence_override or extract_defense_sequence(text)
if sequence:
click_defense_sequence_in_popup(page, sequence)
def fill_error_defense_popup(page: Page, text: str) -> None:
"""실책 수비 팝업 처리"""
defense_sequence = extract_defense_sequence(text)
if len(defense_sequence) >= 2:
click_defense_sequence_in_popup(page, defense_sequence)
else:
error_position = extract_error_position(text)
if not error_position:
error_position = infer_error_position_fallback(text)
click_count = 2 if is_throwing_error(text) else 1
click_defense_button_robustly(page, error_position, click_count)
complete_button = get_last_visible_locator(page, "#btnNext")
if complete_button is None:
complete_button = get_last_visible_locator(page, "#btnAdd")
if complete_button:
complete_button.click()
page.wait_for_timeout(120)

View File

@@ -0,0 +1,141 @@
"""
automation/game_end_input.py — 경기 종료 처리
투수들의 승패/홀드/세이브 기록 등을 경기 종료 팝업에 입력하고 저장합니다.
"""
from __future__ import annotations
from typing import Any
from playwright.sync_api import Page
from automation.page_helpers import show_debug_overlay
from automation.lineup_input import normalize_lineup_text
def _open_game_end_popup(page: Page) -> None:
"""경기 종료 팝업 열기"""
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
page.locator("#gameEndBtn").click(force=True)
page.wait_for_selector("#btnGameEnd", timeout=5000)
page.wait_for_selector("input[name^='homeTeamPitcher_'], input[name^='awayTeamPitcher_']", timeout=5000)
def _get_game_end_pitcher_rows(page: Page) -> dict[str, list[dict[str, Any]]]:
"""팝업 내 홈/원정 투수 리스트 추출"""
return page.evaluate(
"""() => {
const rowsFor = (nameAttr) => {
return [...document.querySelectorAll(`input[name='${nameAttr}']`)].map((input, idx) => {
const tr = input.closest('tr');
const firstTd = tr ? tr.querySelector('td') : null;
return {
idx,
name: firstTd ? firstTd.textContent.trim() : '',
};
});
};
return {
home: rowsFor('home_player_id'),
away: rowsFor('away_player_id'),
};
}"""
)
def _select_game_end_role(page: Page, side: str, idx: int, role_value: str) -> None:
"""특정 투수의 역할(승/패/홀/세 등) 라디오 버튼 선택"""
selector = f"input[name='{side}TeamPitcher_{idx}'][value='{role_value}']"
ok = page.evaluate(
"""(selector) => {
const node = document.querySelector(selector);
if (!node) return false;
node.disabled = false;
node.checked = true;
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
return node.checked === true;
}""",
selector,
)
if not ok:
page.locator(selector).click(force=True)
def _check_game_end_blown_save(page: Page, side: str, idx: int) -> None:
"""블론세이브 체크박스 선택"""
selector = f"input[name='{side}BlownSave_{idx}']"
ok = page.evaluate(
"""(selector) => {
const node = document.querySelector(selector);
if (!node) return false;
node.disabled = false;
node.checked = true;
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
return node.checked === true;
}""",
selector,
)
if not ok:
page.locator(selector).check(force=True)
def fill_game_end_pitching(page: Page, report: dict[str, Any]) -> None:
"""리포트의 투수 요약을 바탕으로 경기 종료 팝업 폼 채우기"""
_open_game_end_popup(page)
rows = _get_game_end_pitcher_rows(page)
summary = report.get("pitching_summary") or {}
lineups = report.get("lineups") or {}
home_starter = normalize_lineup_text(((lineups.get("home_team") or {}).get("starter_pitcher") or {}).get("name") or "")
away_starter = normalize_lineup_text(((lineups.get("away_team") or {}).get("starter_pitcher") or {}).get("name") or "")
winners = {normalize_lineup_text(name) for name in (summary.get("승리투수") or [])}
losers = {normalize_lineup_text(name) for name in (summary.get("패전투수") or [])}
holds = {normalize_lineup_text(name) for name in (summary.get("홀드") or [])}
saves = {normalize_lineup_text(name) for name in (summary.get("세이브") or [])}
blown_saves = {normalize_lineup_text(name) for name in (summary.get("블론세이브") or [])}
fixed_roles = winners | losers | holds | saves
for side, side_rows in rows.items():
starter_name = home_starter if side == "home" else away_starter
for row in side_rows:
name = normalize_lineup_text(row.get("name") or "")
idx = int(row["idx"])
if name in winners:
_select_game_end_role(page, side, idx, "wins")
elif name in losers:
_select_game_end_role(page, side, idx, "loses")
elif name in saves:
_select_game_end_role(page, side, idx, "save")
elif name in holds:
_select_game_end_role(page, side, idx, "holds")
elif name and name != starter_name and name not in fixed_roles:
_select_game_end_role(page, side, idx, "re")
if name in blown_saves:
_check_game_end_blown_save(page, side, idx)
page.wait_for_timeout(300)
show_debug_overlay(
page,
[
"게임종료 팝업 입력 준비 완료",
f"승리: {', '.join(summary.get('승리투수') or []) or '-'}",
f"패전: {', '.join(summary.get('패전투수') or []) or '-'}",
f"홀드: {', '.join(summary.get('홀드') or []) or '-'}",
f"세이브: {', '.join(summary.get('세이브') or []) or '-'}",
f"블론세이브: {', '.join(summary.get('블론세이브') or []) or '-'}",
],
)
def submit_game_end(page: Page) -> None:
"""경기 종료 최종 완료(저장) 버튼 클릭"""
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
page.locator("#btnGameEnd").click(force=True)
page.wait_for_timeout(1500)

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

338
automation/page_helpers.py Normal file
View 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

96
automation/pitch_input.py Normal file
View File

@@ -0,0 +1,96 @@
"""
automation/pitch_input.py — 투구 입력
개별 투구의 구종, 구속, 투구결과를 사이트에 입력합니다.
"""
from __future__ import annotations
from typing import Any
from playwright.sync_api import Page
from core.config_loader import pitch_type_map, pitch_result_map
from automation.page_helpers import set_radio_by_label
def get_pitch_runner_events(
pitch: dict[str, Any], event: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""투구에 연결된 주루 이벤트 반환"""
pitch_runner_events = list(pitch.get("runnerEvents") or [])
if pitch_runner_events:
return pitch_runner_events
if event and event.get("runnerEvents"):
return list(event.get("runnerEvents") or [])
return []
def set_pitch(page: Page, pitch: dict[str, Any], event: dict[str, Any] | None = None) -> None:
"""투구 하나를 사이트에 입력 (구종 + 결과 + 구속)"""
pt_map = pitch_type_map()
pr_map = pitch_result_map()
pitch_type = pt_map.get(pitch.get("pitchType") or "")
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
normalized_text = pitch_result_text.replace(" ", "")
# 피치클락 투수위반 → 볼
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
pitch_result_text = ""
# 폭투/포일 체크
runner_events = get_pitch_runner_events(pitch, event)
is_wild_pitch = any(
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
for re_ in runner_events
)
is_passed_ball = any(
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
for re_ in runner_events
)
if is_wild_pitch:
pitch_result = "폭투-볼"
elif is_passed_ball:
pitch_result = "포일-볼"
else:
if "번트" in normalized_text and "헛스윙" in normalized_text:
pitch_result = "번트시도-스트라이크"
elif "번트" in normalized_text and "파울" in normalized_text:
pitch_result = "번트-파울"
else:
pitch_result = pr_map.get(pitch_result_text)
if not pitch_result and pitch.get("pitchResult") in {"BS", "V"}:
pitch_result = "번트시도-스트라이크"
if not pitch_result and pitch.get("pitchResult") == "BF":
pitch_result = "번트-파울"
if not pitch_result and "고의사구" in pitch_result_text:
pitch_result = "고의사구"
if not pitch_result and "파울플라이" in pitch_result_text and "실책" in pitch_result_text:
pitch_result = "파울플라이-실책"
# 구종 입력
if pitch_type:
set_radio_by_label(page, "evt_ballType", pitch_type)
# 투구 결과 입력
if pitch_result:
set_radio_by_label(page, "evt_batter", pitch_result)
# 구속 입력
speed_input = page.locator("#ballspeed")
speed_input.fill(str(pitch.get("speedKmh") or 0))
speed_input.evaluate("node => node.dispatchEvent(new Event('change', {bubbles:true}))")
page.wait_for_timeout(50)
def set_pitch_meta_only(page: Page, pitch: dict[str, Any]) -> None:
"""구종/구속만 세팅 (인플레이 마지막 구에서 사용)
evt_batter를 건드리지 않아 팝업이 미리 열리는 것을 방지.
"""
pt_map = pitch_type_map()
pitch_type = pt_map.get(pitch.get("pitchType") or "")
if pitch_type:
set_radio_by_label(page, "evt_ballType", pitch_type)
page.locator("#ballspeed").fill(str(pitch.get("speedKmh") or 0))

233
automation/review_input.py Normal file
View File

@@ -0,0 +1,233 @@
"""
automation/review_input.py — 비디오 판독/합의 판정 입력
비디오 판독 이벤트를 관리자 사이트 팝업에 입력합니다.
"""
from __future__ import annotations
import re
from typing import Any
from playwright.sync_api import Page
from core.review_parser import parse_review_event_text
from automation.page_helpers import (
set_select_by_partial_text,
set_select_by_text_or_value,
)
def _normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]:
"""텍스트 기반 이벤트 파싱 및 정규화"""
has_results = review_event.get("beforeResult") is not None and review_event.get("finalResult") is not None
if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results:
return review_event
text = review_event.get("text") or ""
parsed = parse_review_event_text(text)
parsed.update({k: v for k, v in review_event.items() if k not in parsed})
return parsed
def _open_challenge_popup(page: Page) -> Page:
"""비디오 판독 팝업 열기"""
with page.expect_popup(timeout=5000) as popup_info:
page.locator("#challengeBtn").click()
popup = popup_info.value
popup.wait_for_load_state("domcontentloaded")
popup.wait_for_selector("#requestInning_0", timeout=5000)
return popup
def _select_review_final_result(
popup: Page, row_index: int, review_item: str, final_result: str | None,
) -> None:
"""판독 결과 선택"""
from core.config_loader import review_result_groups
groups = review_result_groups()
# 설정에 매핑된 그룹/기본값 찾기
group_info = groups.get(review_item)
if group_info:
group_key = group_info["type"]
default_a = group_info["options"][0]
else:
group_key = "type3"
default_a = "인정"
result_value = final_result or default_a
# 셀렉터 찾기 시도
select_selector = f"#finalResult_{group_key}_{row_index}"
if not popup.locator(select_selector).count():
select_selector = f"#finalResult_{group_key[-1]}_{row_index}"
if not popup.locator(select_selector).count():
select_selector = f"#finalResult_type{group_key[-1]}_{row_index}"
set_select_by_text_or_value(popup, select_selector, result_value)
# JS 강제 이벤트 발생
try:
popup.locator(f"#finalResult_{row_index}").evaluate(
"""(node, value) => {
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
}""",
result_value,
)
except Exception:
pass
def _fill_review_row(popup: Page, row_index: int, review_event: dict[str, Any]) -> None:
"""팝업의 한 행에 판독 이벤트 입력"""
request_inning = review_event.get("requestInningLabel") or "1초"
request_team = review_event.get("requestTeam") or popup.locator(f"#requestTeamId_{row_index} option").first.text_content() or ""
review_item = review_event.get("reviewItem") or "기타"
final_result = review_event.get("finalResult")
is_success_val = "Y" if (review_event.get("isSuccess") == "성공") else "N"
popup.wait_for_selector(f"#requestInning_{row_index}", timeout=3000)
set_select_by_text_or_value(popup, f"#requestInning_{row_index}", request_inning)
try:
set_select_by_partial_text(popup, f"#requestTeamId_{row_index}", request_team)
except Exception:
pass
if popup.is_closed(): return
try:
set_select_by_partial_text(popup, f"#forWhat_{row_index}", review_item)
except Exception:
set_select_by_text_or_value(popup, f"#forWhat_{row_index}", "기타")
popup.wait_for_timeout(100)
if popup.is_closed(): return
_select_review_final_result(popup, row_index, review_item, final_result)
if popup.is_closed(): return
set_select_by_text_or_value(popup, f"#isSuccess_{row_index}", is_success_val)
def _append_review_row(popup: Page) -> int:
"""신규 판독 행 추가"""
before_count = popup.locator("select[id^='requestInning_']").count()
popup.get_by_role("button", name="신규추가").click()
popup.wait_for_function(
"""(expectedCount) => {
return document.querySelectorAll("select[id^='requestInning_']").length > expectedCount;
}""",
arg=before_count,
timeout=3000,
)
row_index = popup.evaluate(
"""() => {
const ids = [...document.querySelectorAll("select[id^='requestInning_']")]
.map((el) => el.id)
.map((id) => Number(id.split("_").pop()))
.filter((num) => Number.isFinite(num));
return ids.length ? Math.max(...ids) : 0;
}"""
)
popup.wait_for_selector(f"#requestInning_{row_index}", timeout=5000)
return int(row_index)
def _can_reuse_initial_review_row(popup: Page) -> bool:
"""초기화된 0번 행 재사용 가능 여부"""
try:
row_count = popup.locator("select[id^='requestInning_']").count()
if row_count != 1:
return False
hidden_id = (popup.locator("#id_0").input_value() or "").strip()
if hidden_id:
return False
request_inning = popup.locator("#requestInning_0").input_value()
request_team = popup.locator("#requestTeamId_0").input_value()
review_item = popup.locator("#forWhat_0").input_value()
final_result = (popup.locator("#finalResult_0").input_value() or "").strip()
is_success = popup.locator("#isSuccess_0").input_value()
return (
request_inning == "1"
and review_item == "홈런타구 페어 파울"
and final_result == "페어"
and is_success == "Y"
and bool(request_team)
)
except Exception:
return False
def _save_review_popup(popup: Page) -> None:
"""팝업 저장 및 닫기"""
if popup.is_closed():
return
popup.evaluate("""() => {
window.confirm = () => true;
window.alert = () => {};
}""")
try:
with popup.expect_response(
re.compile(r"/manager/game/status/challenge/ajax"),
timeout=3000,
) as _:
save_btn = popup.locator("#saveLog")
if save_btn.count() > 0:
save_btn.click(force=True)
else:
popup.evaluate("""() => {
const btn = document.querySelector('#btnAdd')
|| document.querySelector('#btnSave')
|| [...document.querySelectorAll('button, a')].find(
el => el.innerText.includes('입력완료') || el.innerText.includes('저장')
);
if (btn) btn.click();
}""")
except Exception:
try:
popup.evaluate("""() => {
const btn = document.querySelector('#saveLog')
|| document.querySelector('#btnAdd');
if (btn) btn.click();
}""")
popup.wait_for_timeout(1000)
except Exception:
pass
try:
if not popup.is_closed():
popup.close()
except Exception:
pass
def record_review_events(page: Page, review_events: list[dict[str, Any]]) -> None:
"""비디오 판독 이벤트 전체 처리 파이프라인"""
normalized_events = [_normalize_review_event(event) for event in (review_events or [])]
if not normalized_events:
return
popup = _open_challenge_popup(page)
reuse_initial_row = _can_reuse_initial_review_row(popup)
for index, review_event in enumerate(normalized_events):
if index == 0 and reuse_initial_row:
row_index = 0
else:
row_index = _append_review_row(popup)
_fill_review_row(popup, row_index, review_event)
_save_review_popup(popup)
page.wait_for_timeout(300)
try:
page.bring_to_front()
except Exception:
pass

271
automation/runner_input.py Normal file
View File

@@ -0,0 +1,271 @@
"""
automation/runner_input.py — 주루 입력
주루 이벤트(진루, 도루, 견제, 실책 진루 등)를 관리자 사이트에 입력합니다.
"""
from __future__ import annotations
import re
from time import time, sleep
from typing import Any
from playwright.sync_api import Page
from core.field_calculator import is_double_play_result, extract_error_position
from core.runner_classifier import infer_runner_action_label
from automation.page_helpers import (
get_last_visible_enabled_locator,
set_radio_by_label,
get_checked_event_name,
)
from automation.defense_popup import (
fill_error_defense_popup,
fill_runner_out_defense,
)
def _split_complex_runner_event(
runner_event: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""복합 주루 이벤트(예: 1루주자 2루까지 진루 / 홈까지 들어오다 아웃) 분할"""
text = runner_event.get("text") or ""
if "/" not in text or ("실책" not in text and "아웃" not in text):
return runner_event, None
parts = [p.strip() for p in text.split("/") if p.strip()]
if len(parts) < 2:
return runner_event, None
def extract_base(t: str) -> int | None:
m = re.search(r"([123])루", t)
return int(m.group(1)) if m else None
# 1차 이벤트
primary = dict(runner_event)
primary["text"] = parts[0]
intermediate_to = extract_base(parts[0])
if intermediate_to:
primary["toBase"] = intermediate_to
# 2차 이벤트
secondary = dict(runner_event)
secondary["fromBase"] = primary.get("toBase")
secondary["text"] = parts[1]
if "실책" in parts[1]:
secondary["type"] = "error_advance"
elif "태그" in parts[1]:
secondary["type"] = "tag_out"
elif "포스" in parts[1]:
secondary["type"] = "force_out"
else:
secondary["type"] = "out"
return primary, secondary
def _open_runner_area(page: Page, from_base: int, area_type: int) -> None:
"""주루 영역(1: 진루, 2: 액션) 열기"""
function_name = f"changRunnerArea{from_base}"
page.evaluate(
"""({ functionName, areaType }) => {
const fn = window[functionName];
if (typeof fn === 'function') {
fn(areaType);
}
}""",
{"functionName": function_name, "areaType": area_type},
)
deadline = time() + 2
radio_name = f"evt_runner_{from_base}"
advance_name = f"dat_evt_runner_{from_base}_advance"
while time() < deadline:
ready = page.evaluate(
"""({ radioName, advanceName }) => {
const hasEnabledVisibleRadio = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)].some((node) => {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
});
const hasEnabledVisibleAdvance = [...document.querySelectorAll(`input[type=radio][name='${advanceName}']`)].some((node) => {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
});
return hasEnabledVisibleRadio || hasEnabledVisibleAdvance;
}""",
{"radioName": radio_name, "advanceName": advance_name},
)
if ready:
return
sleep(0.1)
def _set_runner_action(page: Page, from_base: int, label: str) -> None:
"""주자 액션(일반진루, 도루성공 등) 라디오 버튼 세팅"""
radio_name = f"evt_runner_{from_base}"
locator = page.evaluate(
r"""({ radioName, eventName }) => {
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)];
// 1단계: eventName 속성으로 매칭
for (const node of nodes) {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) {
continue;
}
if ((node.getAttribute('eventName') || '') === eventName) {
const marker = `codex-runner-${Math.random().toString(36).slice(2)}`;
node.setAttribute('data-codex-marker', marker);
return marker;
}
}
// 2단계: 텍스트로 부분 매칭
for (const node of nodes) {
let text = '';
let p = node.parentElement;
if (p) text = p.textContent.trim();
if (!text && node.nextSibling) text = node.nextSibling.textContent || '';
if (eventName && text.replace(/\s/g, '').includes(eventName.replace(/\s/g, ''))) {
const marker = `codex-runner-${Math.random().toString(36).slice(2)}`;
node.setAttribute('data-codex-marker', marker);
return marker;
}
}
return null;
}""",
{"radioName": radio_name, "eventName": label},
)
if locator:
candidate = page.locator(f"[data-codex-marker='{locator}']")
for _ in range(5):
try:
candidate.click(force=True, timeout=500)
except Exception:
pass
try:
candidate.evaluate(
"""(node) => {
node.disabled = false;
node.checked = true;
node.dispatchEvent(new Event('click', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}"""
)
except Exception:
pass
page.wait_for_timeout(100)
if get_checked_event_name(page, radio_name) == label:
return
else:
for _ in range(5):
set_radio_by_label(page, radio_name, label)
page.wait_for_timeout(50)
if get_checked_event_name(page, radio_name) == label:
return
def _set_runner_advance(page: Page, from_base: int, to_base: int | None) -> None:
"""주자 최종 목적지 루 세팅"""
if to_base is None:
return
selector = f"input[name='dat_evt_runner_{from_base}_advance'][value='{to_base}']"
deadline = time() + 3
locator = None
while time() < deadline:
locator = get_last_visible_enabled_locator(page, selector)
if locator is not None:
break
sleep(0.1)
if locator is None:
fallback = page.locator(selector)
if fallback.count():
locator = fallback.last
else:
return # timeout
try:
locator.click(force=True)
except Exception:
locator.evaluate(
"""(node) => {
node.disabled = false;
node.checked = true;
node.dispatchEvent(new Event('click', { bubbles: true }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}"""
)
def set_runner_events(
page: Page, event: dict[str, Any], runner_events: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""모든 주루 이벤트를 처리하고 지연 처리할 이벤트(late_events) 반환"""
if runner_events is None:
runner_events = (event.get("runnerEvents") or []).copy()
late_events = []
filtered_events = []
for re_item in runner_events:
primary, secondary = _split_complex_runner_event(re_item)
filtered_events.append(primary)
if secondary:
late_events.append(secondary)
for runner_event in filtered_events:
from_base = runner_event.get("fromBase")
if from_base not in {1, 2, 3}:
continue
label = infer_runner_action_label(event, runner_event)
if not label:
continue
if any(k in label for k in ["도루", "견제", "폭투", "포일", "아웃"]):
area_type = 2
else:
area_type = 1
if any(k in label for k in ["일반 진루", "볼넷 진루", "수비 실책"]):
area_type = 1
_open_runner_area(page, from_base, area_type)
_set_runner_action(page, from_base, label)
runner_text = runner_event.get("text") or ""
is_error_related_label = (
(label and "실책" in label)
or label in {"견제 에러", "수비 실책", "도루성공&실책"}
or "실책" in runner_text
)
if is_error_related_label:
if extract_error_position(runner_text):
fill_error_defense_popup(page, runner_text)
if label in {"태그아웃", "도루시도 아웃", "포스아웃"}:
fill_runner_out_defense(page, runner_text)
page.wait_for_timeout(150)
_set_runner_advance(page, from_base, runner_event.get("toBase"))
try:
extra_advance = runner_event.get("extra_advance")
if extra_advance and extra_advance > 0:
locator = get_last_visible_enabled_locator(page, f"select[name='runner{from_base}_running_add']")
if locator is not None:
locator.select_option(value=str(extra_advance))
except Exception:
pass
page.wait_for_timeout(150)
return late_events

161
cli.py Normal file
View File

@@ -0,0 +1,161 @@
"""
cli.py — 시스템 전체 CLI 통합 진입점
크롤러, 라인업 관리, 게임 기록 입력 등 모든 기능을
서브 커맨드(subcommand) 형태로 실행할 수 있습니다.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
from commands.base import add_common_arguments, load_report
from commands.record import run as run_record
DEFAULT_BASE_URL = "http://58.229.253.168:8089"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Baseball Automation CLI")
subparsers = parser.add_subparsers(dest="command", required=True)
# 1. record 커맨드 (기존 record_game_playwright.py)
parser_record = subparsers.add_parser("record", help="크롤링된 리포트를 바탕으로 게임 기록을 자동 입력합니다.")
add_common_arguments(parser_record)
parser_record.add_argument("--review-only", action="store_true", help="경기기록 대신 합의판정만 일괄 등록")
# 2. crawl 커맨드 (네이버 API 크롤링)
parser_crawl = subparsers.add_parser("crawl", help="네이버 야구 API에서 데이터를 크롤링하여 JSON 리포트로 저장합니다.")
parser_crawl.add_argument("--game-id", required=True, help="크롤링할 네이버 게임 ID (예: 20260501NCLG02026)")
parser_crawl.add_argument("--output-dir", default="output", help="리포트를 저장할 디렉토리 (기본값: output)")
parser_crawl.add_argument("--start-inning", help="시작 이닝 필터 (예: '2', '3초')")
parser_crawl.add_argument("--end-inning", help="종료 이닝 필터 (예: '5', '7말')")
parser_crawl.add_argument("--lineup-only", action="store_true", help="라인업 정보만 저장")
return parser.parse_args()
def interactive_mode() -> int:
print("=== Baseball Automation CLI ===")
print("1. 네이버 데이터 크롤링 (crawl)")
print("2. 관리자 사이트 라인업 입력 (lineup)")
print("3. 관리자 사이트 기록 자동 입력 (record)")
print("4. 크롤링 + 라인업 + 기록 한 번에 실행 (crawl -> lineup -> record)")
choice = input("\n실행할 작업을 선택하세요 (1/2/3/4) [4]: ").strip() or "4"
if choice not in {"1", "2", "3", "4"}:
print("잘못된 입력입니다.")
return 1
game_id = input("경기 ID를 입력하세요 (예: 20260501NCLG02026): ").strip()
if not game_id:
print("경기 ID가 필요합니다.")
return 1
base_url = DEFAULT_BASE_URL
user_data_dir = None
manager_game_no = ""
if choice in {"2", "3", "4"}:
base_url_input = input("기록 사이트 기본 URL을 입력하세요 (엔터 시 site.txt 자동 참조): ").strip()
if base_url_input:
base_url = base_url_input
else:
try:
site_txt_path = Path("site.txt")
if site_txt_path.exists():
lines = site_txt_path.read_text(encoding="utf-8").splitlines()
if lines and lines[0].startswith("http"):
from urllib.parse import urlparse
parsed = urlparse(lines[0])
base_url = f"{parsed.scheme}://{parsed.netloc}"
print(f"👉 [자동 설정] 기록 사이트 URL: {base_url}")
except Exception as e:
print(f"site.txt 파일을 읽는 중 오류가 발생했습니다: {e}")
return 1
user_data_dir = input("크롬 프로필 경로를 입력하세요 (엔터 시 임시 세션): ").strip()
manager_game_no = input("관리자 사이트 게임번호를 입력하세요 (예: 11211, 모르면 엔터): ").strip()
args = argparse.Namespace(
game_id=game_id,
base_url=base_url,
report_path=None,
manager_game_no=manager_game_no or None,
user_data_dir=user_data_dir or None,
channel="chrome",
headless=False,
close=False,
write_events=True,
job_id=None,
lineup_only=False,
review_only=False,
start_inning=None,
end_inning=None,
output_dir="output",
save=True, # for lineup
)
if choice in {"1", "4"}:
from crawler.report_builder import build_report, filter_report, save_report
print(f"\n[{args.game_id}] 데이터 크롤링 시작...")
report = build_report(args.game_id)
filtered = filter_report(report)
out_path = save_report(filtered, Path(args.output_dir))
print(f"✅ 크롤링 및 리포트 저장 완료: {out_path}")
if choice in {"2", "3", "4"}:
report_path = Path(args.output_dir) / f"{args.game_id}_report.json"
report = load_report(report_path)
with sync_playwright() as playwright:
if choice in {"2", "4"}:
from commands.lineup import run as run_lineup
run_lineup(playwright, args, report)
if choice in {"3", "4"}:
from commands.record import run as run_record
print(f"\n[{args.game_id}] 관리자 사이트 기록 자동 입력 시작...")
run_record(playwright, args, report)
return 0
def main() -> int:
if len(sys.argv) == 1:
return interactive_mode()
args = _parse_args()
if args.game_id:
args.game_id = "".join(args.game_id.split())
if args.command == "record":
report_path = Path(args.report_path) if args.report_path else Path("output") / f"{args.game_id}_report.json"
report = load_report(report_path)
with sync_playwright() as playwright:
run_record(playwright, args, report)
return 0
if args.command == "crawl":
from crawler.report_builder import build_report, filter_report, save_report
print(f"[{args.game_id}] 데이터 크롤링 시작...")
report = build_report(args.game_id, start_inning=args.start_inning, end_inning=args.end_inning)
filtered = filter_report(
report,
lineup_only=getattr(args, "lineup_only", False),
start_inning=args.start_inning,
end_inning=args.end_inning
)
out_path = save_report(filtered, Path(args.output_dir))
print(f"✅ 크롤링 및 리포트 저장 완료: {out_path}")
return 0
return 1
if __name__ == "__main__":
sys.exit(main())

52
commands/base.py Normal file
View File

@@ -0,0 +1,52 @@
"""
commands/base.py — 공통 CLI 명령어 유틸리티
브라우저 컨텍스트 초기화, 리포트 파일 로드 등 여러 명령어에서 공통으로
사용하는 기능을 제공합니다.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from playwright.sync_api import Playwright, BrowserContext
def launch_browser_context(
playwright: Playwright, user_data_dir: str | None, channel: str | None, headless: bool
) -> BrowserContext:
"""공용 Playwright 브라우저 컨텍스트 생성"""
if user_data_dir:
return playwright.chromium.launch_persistent_context(
user_data_dir=user_data_dir,
channel=channel,
headless=headless,
args=["--disable-blink-features=AutomationControlled"],
viewport={"width": 1280, "height": 800},
)
browser = playwright.chromium.launch(channel=channel, headless=headless)
return browser.new_context(viewport={"width": 1280, "height": 800})
def load_report(path: Path) -> dict[str, Any]:
"""JSON 리포트 파일 로드"""
if not path.is_file():
raise FileNotFoundError(f"리포트 파일을 찾을 수 없습니다: {path}")
with path.open(encoding="utf-8") as f:
return json.load(f)
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
"""명령행 인자에 브라우저/설정 관련 공통 옵션 추가"""
parser.add_argument("--base-url", required=True, help="기록 시스템 기본 URL")
parser.add_argument("--game-id", required=True, help="크롤링할 네이버 게임 ID (예: 20260501NCLG02026)")
parser.add_argument("--report-path", help="로컬 리포트 JSON 경로 (기본값: output/{game_id}_report.json)")
parser.add_argument("--manager-game-no", help="관리자 사이트의 게임번호 (생략 시 목록에서 검색)")
parser.add_argument("--user-data-dir", help="Chrome 사용자 프로필 경로 (로그인 유지용)")
parser.add_argument("--channel", default="chrome", help="브라우저 채널 (기본값: chrome)")
parser.add_argument("--headless", action="store_true", help="브라우저 숨김 모드 실행")
parser.add_argument("--close", action="store_true", help="작업 완료 후 브라우저 닫기")
parser.add_argument("--no-write", dest="write_events", action="store_false", help="실제 입력은 하지 않고 시뮬레이션만 수행")
parser.add_argument("--job-id", help="DB 로깅용 작업 ID (UUID)")

212
commands/lineup.py Normal file
View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import argparse
import re
from typing import Any
from playwright.sync_api import Page, Playwright
from core.config_loader import position_to_defense_no
from core.normalizer import normalize_stadium_name, normalize_team_name
from commands.base import launch_browser_context
def normalize_number_text(number: str | int | None) -> str:
text = str(number or "").strip()
digits = "".join(char for char in text if char.isdigit())
if not digits:
return ""
return str(int(digits))
def normalize_player_name_text(name: str | None) -> str:
text = (name or "").replace("*", "").strip()
text = re.sub(r"\([^)]*\)\s*$", "", text).strip()
return text
def normalize_option_player_text(text: str) -> tuple[str, str]:
stripped = " ".join(text.split())
matched = re.match(r"^(.*?)\s*\[(\d+)번\]$", stripped)
if matched:
return normalize_player_name_text(matched.group(1)), normalize_number_text(matched.group(2))
return normalize_player_name_text(stripped), ""
def infer_option_role_hint(text: str) -> str:
stripped = " ".join(text.split())
matched = re.search(r"\(([^)]*)\)\s*(?:\[\d+번\])?$", stripped)
if not matched:
return ""
hint = matched.group(1).strip()
if hint == "":
return "pitcher"
if hint == "":
return "batter"
return ""
def infer_target_role_hint(position_name: str | None) -> str:
if position_name == "투수":
return "pitcher"
return "batter"
def get_select_options(page: Page, selector: str) -> list[dict[str, str]]:
return page.locator(selector).evaluate(
"""(el) => [...el.options].map(option => ({
value: option.value,
text: option.textContent.trim()
}))"""
)
def select_player_option(
page: Page,
selector: str,
player_name: str,
player_number: str | None,
position_name: str | None = None,
) -> None:
options = get_select_options(page, selector)
target_number = normalize_number_text(player_number)
normalized_player_name = normalize_player_name_text(player_name)
target_role_hint = infer_target_role_hint(position_name)
name_matches = []
role_filtered_matches = []
for option in options:
option_name, option_number = normalize_option_player_text(option["text"])
if option_name != normalized_player_name:
continue
name_matches.append(option)
option_role_hint = infer_option_role_hint(option["text"])
role_matches = (
not target_role_hint
or not option_role_hint
or option_role_hint == target_role_hint
)
if role_matches:
role_filtered_matches.append(option)
if target_number and option_number == target_number:
if not option_role_hint or option_role_hint == target_role_hint:
page.locator(selector).select_option(value=option["value"])
return
if len(role_filtered_matches) == 1:
page.locator(selector).select_option(value=role_filtered_matches[0]["value"])
return
if len(name_matches) == 1:
page.locator(selector).select_option(value=name_matches[0]["value"])
return
normalized_options = [normalize_option_player_text(option["text"]) for option in options]
similar_options = [
option["text"]
for option, (option_name, option_number) in zip(options, normalized_options)
if normalized_player_name in option_name or option_name in normalized_player_name or (target_number and option_number == target_number)
]
if not similar_options:
similar_options = [option["text"] for option in options]
raise ValueError(
f"{selector}에서 선수 '{player_name}' #{player_number} 옵션을 찾지 못했습니다. "
f"후보: {', '.join(similar_options[:10])}"
)
def select_position_option(page: Page, selector: str, position_name: str) -> None:
position_value = position_to_defense_no().get(position_name)
if not position_value:
raise ValueError(f"포지션 매핑이 없습니다: {position_name}")
page.locator(selector).select_option(value=position_value)
def build_lineup_entries(lineups: dict[str, Any], team_key: str) -> list[tuple[int, dict[str, Any]]]:
team_lineup = lineups[team_key]
entries = [(0, team_lineup["starter_pitcher"])]
entries.extend((int(player["bat_order"]), player) for player in team_lineup["players"])
return entries
def fill_lineup_form(page: Page, report: dict[str, Any]) -> None:
lineups = report["lineups"]
team_selector_map = {
"home_team": "home",
"away_team": "away",
}
for team_key, prefix in team_selector_map.items():
for order, player in build_lineup_entries(lineups, team_key):
if not player:
continue
player_selector = f"#{prefix}_player_id_{order}"
defense_selector = f"#{prefix}_defense_no_{order}"
select_player_option(page, player_selector, player["name"], player.get("number"), player.get("position"))
select_position_option(page, defense_selector, player["position"])
def find_edit_href(page: Page, report: dict[str, Any], manager_game_no: str | None) -> str:
game_info = report["game_info"]
target_date = game_info["date"]
target_stadium = normalize_stadium_name(game_info["stadium"])
target_home_team = normalize_team_name(game_info["home_team"])
target_away_team = normalize_team_name(game_info["away_team"])
rows: list[dict[str, Any]] = page.locator("table.gclist tr").evaluate_all(
"""(rows) => rows.slice(1).map((row) => {
const cells = [...row.cells].map((cell) => cell.innerText.trim());
const editLink = [...row.querySelectorAll('a')].find((anchor) => anchor.textContent.trim() === '수정');
return {
gameNo: cells[0] || '',
date: cells[1] || '',
gameType: cells[2] || '',
stadium: cells[3] || '',
homeTeam: cells[4] || '',
awayTeam: cells[5] || '',
href: editLink ? editLink.getAttribute('href') : '',
};
})"""
)
if manager_game_no:
matched = next((row for row in rows if row["gameNo"] == str(manager_game_no)), None)
if not matched or not matched["href"] or matched["href"].startswith("javascript:"):
raise ValueError(f"관리자 게임번호 {manager_game_no} 행의 수정 링크를 찾지 못했습니다.")
return matched["href"]
candidates = [
row for row in rows if row["href"] and not row["href"].startswith("javascript:")
and row["date"] == target_date
and normalize_stadium_name(row["stadium"]) == target_stadium
and normalize_team_name(row["homeTeam"]) == target_home_team
and normalize_team_name(row["awayTeam"]) == target_away_team
]
if not candidates:
raise ValueError("목록에서 일치하는 경기 수정 행을 찾지 못했습니다.")
return candidates[0]["href"]
def open_edit_page(page: Page, base_url: str, report: dict[str, Any], manager_game_no: str | None) -> None:
if manager_game_no:
page.goto(f"{base_url}/manager/game/write?id={manager_game_no}", wait_until="domcontentloaded")
page.wait_for_selector("#gameFrm", timeout=10000)
page.wait_for_selector("#home_player_id_1", timeout=10000)
return
page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded")
page.wait_for_selector("table.gclist", timeout=10000)
edit_href = find_edit_href(page, report, manager_game_no)
with page.expect_navigation(wait_until="domcontentloaded"):
page.locator(f"a[href='{edit_href}']").first.click()
page.wait_for_selector("#gameFrm", timeout=10000)
page.wait_for_selector("#home_player_id_1", timeout=10000)
def run(playwright: Playwright, args: argparse.Namespace, report: dict[str, Any]) -> None:
browser = launch_browser_context(playwright, args.user_data_dir, args.channel, args.headless)
page = browser.pages[0] if browser.pages else browser.new_page()
try:
print(f"[{args.game_id}] 관리자 사이트 라인업 입력 시작...")
open_edit_page(page, args.base_url, report, args.manager_game_no)
fill_lineup_form(page, report)
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
page.locator("#lineupWriteBtn").click()
page.wait_for_timeout(1000)
print(f"✅ 라인업 저장 완료")
finally:
if args.close:
try:
browser.close()
except Exception:
pass

556
commands/record.py Normal file
View File

@@ -0,0 +1,556 @@
"""
commands/record.py — 게임 전체 기록 (Main Command)
JSON 리포트를 읽고 관리자 사이트의 폼을 제어하여
경기 전체를 순차적으로 입력합니다.
"""
from __future__ import annotations
import argparse
import os
import re
from time import time, sleep
from typing import Any
from playwright.sync_api import Playwright, Page
from core.normalizer import (
normalize_game_type,
normalize_stadium_name,
normalize_team_name,
)
from core.pitch_classifier import infer_batter_result_label, get_last_pitch_result_text
from core.field_calculator import extract_defense_sequence, extract_error_position, is_throwing_error, is_error_result
from automation.page_helpers import (
get_last_visible_locator,
get_checked_event_name,
set_radio_by_label,
show_debug_overlay,
wait_for_operator_control,
get_checked_batter_defense_type,
get_history_count,
)
from automation.defense_popup import fill_error_defense_popup, click_defense_button_robustly
from automation.pitch_input import set_pitch, set_pitch_meta_only, get_pitch_runner_events
from automation.batter_input import set_batter_result_type, set_batter_advancement, set_hit_ball_and_defense
from automation.runner_input import set_runner_events
from automation.lineup_input import apply_change_event
from automation.review_input import record_review_events
from commands.base import launch_browser_context, load_report, add_common_arguments
_DEFENSE_CLEAR_JS = """
() => {
document.querySelectorAll("input[name='defenseNumberBtn']").forEach(el => { el.checked = false; });
document.querySelectorAll("input[name='hitBallType']").forEach(el => { el.checked = false; });
const datIds = [
"putout", "assist", "error", "upstruction",
"dat_putout_hitter", "dat_assist_hitter", "dat_error_hitter", "dat_upstruction_hitter",
"dat_putout_runner1", "dat_assist_runner1", "dat_error_runner1", "dat_upstruction_runner1",
"dat_putout_runner2", "dat_assist_runner2", "dat_error_runner2", "dat_upstruction_runner2",
"dat_putout_runner3", "dat_assist_runner3", "dat_error_runner3", "dat_upstruction_runner3",
"dat_error_type", "dat_error_type1", "dat_error_type2", "dat_error_type3",
"dat_multiplay_type", "multiplay_type",
"hitBallXY", "hitBallDistance",
"dat_hitball_speed", "dat_hitball_type", "dat_hitball_x", "dat_hitball_y", "dat_hitball_xy", "dat_hitball_distance",
"hitball_speed", "hitball_type", "hitball_xy", "hitball_distance"
];
datIds.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = "";
});
document.querySelectorAll("input[name='hitBallType'], [id^='hitBallSpeed'], [id^='hitBallType']").forEach(el => {
if (el.type === 'radio' || el.type === 'checkbox') {
el.checked = false;
} else {
el.value = "";
}
});
}
"""
def clear_defense_selections(page: Page) -> None:
page.evaluate(_DEFENSE_CLEAR_JS)
def is_simple_terminal_result_type(result_type: str) -> bool:
return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"}
def advance_count(balls: int, strikes: int, pitch_result: str) -> tuple[int, int]:
if pitch_result == "B":
return min(balls + 1, 3), strikes
if pitch_result in {"T", "S"}:
return balls, min(strikes + 1, 2)
if pitch_result == "F":
return balls, strikes if strikes >= 2 else strikes + 1
return balls, strikes
def find_status_href(page: Page, report: dict[str, Any], manager_game_no: str | None) -> str:
game_info = report["game_info"]
target_date = game_info["date"]
target_stadium = normalize_stadium_name(game_info["stadium"])
target_home_team = normalize_team_name(game_info["home_team"])
target_away_team = normalize_team_name(game_info["away_team"])
target_game_type = normalize_game_type(game_info["game_type"])
rows: list[dict[str, Any]] = page.locator("table.gclist tr").evaluate_all(
"""(rows) => rows.slice(1).map((row) => {
const cells = [...row.cells].map((cell) => cell.innerText.trim());
const statusLink = [...row.querySelectorAll('a')].find((anchor) => anchor.textContent.trim() === '게임기록');
return {
gameNo: cells[0] || '',
date: cells[1] || '',
gameType: cells[2] || '',
stadium: cells[3] || '',
homeTeam: cells[4] || '',
awayTeam: cells[5] || '',
href: statusLink ? statusLink.getAttribute('href') : '',
};
})"""
)
if manager_game_no:
matched = next((row for row in rows if row["gameNo"] == str(manager_game_no)), None)
if not matched or not matched["href"] or matched["href"].startswith("javascript:"):
raise ValueError(f"관리자 게임번호 {manager_game_no} 행의 게임기록 링크를 찾지 못했습니다.")
return matched["href"]
candidates = [
row for row in rows if row["href"] and not row["href"].startswith("javascript:")
and row["date"] == target_date
and (not row["gameType"] or normalize_game_type(row["gameType"]) == target_game_type)
and normalize_stadium_name(row["stadium"]) == target_stadium
and normalize_team_name(row["homeTeam"]) == target_home_team
and normalize_team_name(row["awayTeam"]) == target_away_team
]
if not candidates:
print("\n=== 게임 매칭 실패 디버그 정보 ===")
print(f"Target: Date='{target_date}', Type='{target_game_type}', Stadium='{target_stadium}', Home='{target_home_team}', Away='{target_away_team}'")
print("Rows parsed from table:")
for r in rows:
print(f" - Date='{r['date']}', Type='{r['gameType']}'(norm: '{normalize_game_type(r['gameType'])}'), Stadium='{r['stadium']}'(norm: '{normalize_stadium_name(r['stadium'])}'), Home='{r['homeTeam']}'(norm: '{normalize_team_name(r['homeTeam'])}'), Away='{r['awayTeam']}'(norm: '{normalize_team_name(r['awayTeam'])}')")
raise ValueError("목록에서 일치하는 게임기록 링크를 찾지 못했습니다.")
return candidates[0]["href"]
def open_game_status_page(page: Page, base_url: str, report: dict[str, Any], manager_game_no: str | None) -> None:
if manager_game_no:
page.goto(f"{base_url}/manager/game/status?game_no={manager_game_no}", wait_until="domcontentloaded")
page.wait_for_selector("#eventWriteBtn", timeout=10000)
return
page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded")
page.wait_for_selector("table.gclist", timeout=10000)
status_href = find_status_href(page, report, manager_game_no)
with page.expect_navigation(wait_until="domcontentloaded"):
page.locator(f"a[href='{status_href}']").first.click()
page.wait_for_selector("#eventWriteBtn", timeout=10000)
def _try_log_pitch(log_info: dict[str, Any] | None, is_success: bool, error_code: str, error_detail: str, duration: float) -> None:
if log_info and log_info.get("job_id"):
try:
from db_logging import log_pitch
log_pitch(
log_info["job_id"], log_info.get("inning", ""), log_info.get("batter", ""),
log_info.get("pitch_no", 0), log_info.get("target_value", ""), log_info.get("selected_value", ""),
is_success, error_code, error_detail, duration
)
except Exception:
pass
def _try_log_event(log_info: dict[str, Any] | None, is_success: bool, error_msg: str = "") -> None:
if log_info and log_info.get("job_id"):
try:
from db_logging import log_event
log_event(
log_info["job_id"], log_info.get("inning", ""), log_info.get("event_type", ""),
log_info.get("target_player", ""), log_info.get("actual_player", ""),
is_success, error_msg
)
except Exception:
pass
def submit_input_complete(page: Page, debug_label: str = "", clear_defense: bool = False, log_info: dict[str, Any] | None = None) -> None:
t0 = time()
try:
page.evaluate("""() => {
window.confirm = () => true;
window.alert = () => {};
const defenseDiv = document.querySelector('#defenseDiv');
if (defenseDiv && defenseDiv.style.display !== 'none') {
const btnAdd = document.querySelector('#btnAdd');
if (btnAdd) btnAdd.click();
}
}""")
if clear_defense:
clear_defense_selections(page)
prev_history = get_history_count(page)
for i in range(40):
curr_history = get_history_count(page)
if curr_history > prev_history:
page.wait_for_timeout(30)
_try_log_pitch(log_info, True, "", "", time() - t0)
return
if i % 8 == 0:
submit_btn = get_last_visible_locator(page, "#eventWriteBtn")
if not submit_btn:
submit_btn = page.get_by_role("button", name="입력완료").last
if submit_btn:
try:
submit_btn.click(force=True, timeout=500)
except Exception:
page.evaluate("document.querySelector('#eventWriteBtn')?.click() || [...document.querySelectorAll('a, button')].find(el => el.innerText.includes('입력완료'))?.click()")
page.wait_for_timeout(50)
page.evaluate("() => { window.confirm = () => true; window.alert = () => {}; }")
raise TimeoutError(f"입력완료가 반영되지 않았습니다: {debug_label}")
except Exception as e:
_try_log_pitch(log_info, False, type(e).__name__, str(e), time() - t0)
raise e
def handle_late_runner_events(page: Page, event: dict[str, Any], late_events: list[dict[str, Any]], write_events: bool, job_id: str | None = None) -> None:
if not late_events or not write_events:
return
page.wait_for_timeout(800)
from automation.page_helpers import get_last_history_text
current_history = get_last_history_text(page)
all_matched = True
for le in late_events:
le_text = le.get("text", "")
if le_text and le_text not in current_history:
all_matched = False
break
if all_matched:
return
new_late = set_runner_events(page, event, late_events)
submit_input_complete(
page,
f"지연 주루 처리: {', '.join(e.get('text', '') for e in late_events)}",
clear_defense=True,
log_info={"job_id": job_id} if job_id else None
)
if new_late:
handle_late_runner_events(page, event, new_late, write_events, job_id)
def build_runner_event_lines(event: dict[str, Any]) -> list[str]:
from core.runner_classifier import infer_runner_action_label
lines: list[str] = []
for runner_event in (event.get("runnerEvents") or []):
r_text = runner_event.get("text", "")
from_b = runner_event.get("fromBase", "?")
to_b = runner_event.get("toBase", "?")
label = infer_runner_action_label(event, runner_event)
line = f"🏃 {from_b}루주자 -> {to_b}루 : {r_text}"
if label:
line += f" | 라벨: {label}"
lines.append(line)
return lines
def process_only_reviews(page: Page, report: dict[str, Any], write_events: bool, job_id: str = None) -> None:
all_reviews = []
from automation.review_input import _normalize_review_event
for half_inning in report.get("game_contents", []):
for event in half_inning.get("events", []):
if event.get("event_type") == "at_bat":
reviews = event.get("reviewEvents") or []
for r in reviews:
all_reviews.append(_normalize_review_event(r))
if not all_reviews:
show_debug_overlay(page, ["입력할 합의판정 기록이 없습니다."])
page.wait_for_timeout(2000)
return
show_debug_overlay(page, [f"합의판정 {len(all_reviews)}건 일괄 등록 시작"])
if write_events:
record_review_events(page, all_reviews)
show_debug_overlay(page, ["합의판정 일괄 등록 완료"])
page.wait_for_timeout(1000)
def process_report(page: Page, report: dict[str, Any], write_events: bool, job_id: str = None) -> None:
from core.pitch_classifier import normalize_pitch_result_code
outs = 0
change_cache: dict[str, tuple[str, int]] = {}
applied_change_texts: set[str] = set()
for half_inning in report.get("game_contents", []):
inning = half_inning.get("inning", "")
outs = 0
for event in half_inning.get("events", []):
if event.get("event_type") == "change":
change_text = (event.get("text") or "").strip()
show_debug_overlay(page, [f"교체 입력: {change_text or '-'}"])
wait_for_operator_control(page)
if write_events:
if change_text and change_text in applied_change_texts:
show_debug_overlay(page, ["교체 중복 건너뜀", change_text])
page.wait_for_timeout(250)
continue
log_info_event = {
"job_id": job_id, "inning": inning, "event_type": event.get("change_type", "change"),
"target_player": event.get("in_player") or event.get("to_position", ""),
"actual_player": event.get("actor_name") or event.get("player_name", "")
} if job_id else None
try:
apply_change_event(page, half_inning, event, change_cache)
_try_log_event(log_info_event, True)
except Exception as e:
_try_log_event(log_info_event, False, str(e))
raise e
if change_text:
applied_change_texts.add(change_text)
show_debug_overlay(page, ["교체 완료", f"{change_text or '-'}"])
page.wait_for_timeout(120)
continue
if event.get("event_type") != "at_bat":
continue
clear_defense_selections(page)
balls = 0
strikes = 0
pitches = event.get("pitches") or []
result = event.get("result") or {}
for pitch_index, pitch in enumerate(pitches):
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
normalized_pitch_result = normalize_pitch_result_code(pitch, event)
is_balk_strike = "보크" in pitch_result_text and ("스트라이크" in pitch_result_text or "헛스윙" in pitch_result_text)
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
pitch_result_text = "피치클락 투수위반 볼"
show_debug_overlay(
page,
[
f"다음 카운트: {balls}{strikes}스트 {outs}아웃",
f"다음 공: {pitch.get('pitchNo')}{pitch_result_text}",
f"구종/구속: {(pitch.get('pitchType') or '-')} / {(pitch.get('speedKmh') or '-')}",
f"타석: {event.get('batter') or '-'}",
],
)
wait_for_operator_control(page)
is_last_pitch = pitch_index == len(pitches) - 1
is_action_result = is_last_pitch and result.get("type") in {
"hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple",
"home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "bunt_hit",
"single_runner_out", "double_runner_out", "triple_runner_out"
}
is_in_play = (pitch.get("pitchResult") == "H") or is_action_result
if is_last_pitch and is_in_play:
continue
if is_balk_strike:
if write_events:
current_late = []
p_runner_events = pitch.get("runnerEvents")
if p_runner_events:
current_late.extend(set_runner_events(page, event, p_runner_events))
if is_last_pitch and event.get("runnerEvents"):
current_late.extend(set_runner_events(page, event))
submit_input_complete(
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크", clear_defense=True,
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": "보크", "selected_value": "보크"} if job_id else None
)
if current_late:
handle_late_runner_events(page, event, current_late, True, job_id)
page.wait_for_timeout(80)
set_pitch_meta_only(page, pitch)
if "헛스윙" in pitch_result_text:
set_radio_by_label(page, "evt_batter", "헛스윙(스트라이크)")
else:
set_radio_by_label(page, "evt_batter", "스트라이크(루킹)")
if write_events:
submit_input_complete(
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크 후 {'헛스윙' if '헛스윙' in pitch_result_text else '스트라이크'}", clear_defense=True,
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": pitch_result_text, "selected_value": pitch_result_text} if job_id else None
)
else:
set_pitch(page, pitch, event)
if write_events:
current_late = []
p_runner_events = get_pitch_runner_events(pitch, event)
is_wild_pitch = any(re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") for re in p_runner_events)
is_passed_ball = any(re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") for re in p_runner_events)
extra_log = " (폭투)" if is_wild_pitch else " (포일)" if is_passed_ball else ""
if p_runner_events:
current_late.extend(set_runner_events(page, event, p_runner_events))
if is_last_pitch and event.get("runnerEvents"):
current_late.extend(set_runner_events(page, event))
if "파울플라이" in pitch_result_text and "실책" in pitch_result_text:
fill_error_defense_popup(page, pitch_result_text)
submit_input_complete(
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}{pitch_result_text or '-'}{extra_log}", clear_defense=True,
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": f"{pitch_result_text}{extra_log}", "selected_value": "폭투-볼" if is_wild_pitch else "포일-볼" if is_passed_ball else pitch_result_text} if job_id else None
)
if current_late:
handle_late_runner_events(page, event, current_late, True, job_id)
balls, strikes = advance_count(balls, strikes, normalized_pitch_result)
if result:
last_pitch = pitches[-1] if pitches else {}
action_result_types = {
"hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple",
"home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "bunt_hit",
"single_runner_out", "double_runner_out", "triple_runner_out", "play"
}
if last_pitch.get("pitchResult") == "H" or result.get("type") in action_result_types:
runner_lines = build_runner_event_lines(event)
result_text = result.get('text') or ''
def_seq = []
if is_error_result(result_text):
err_pos = extract_error_position(result_text)
if err_pos:
click_count = 2 if is_throwing_error(result_text) else 1
def_seq = [err_pos] * click_count
elif result.get("type") in {"out", "double_play", "sacrifice_fly", "sacrifice_bunt", "strikeout", "reach_on_fielder_choice", "reach_on_grounder", "single_runner_out", "double_runner_out", "triple_runner_out", "play"}:
def_seq = extract_defense_sequence(result_text)
if "직선타" in result_text or "라인드라이브" in result_text or "플라이" in result_text:
if def_seq:
def_seq = [def_seq[0]]
defense_lines = [f"⚾ 누를 수비수: {', '.join(def_seq)}"] if def_seq else []
show_debug_overlay(
page,
[
f"📌 타격 결과: {result_text or '-'}",
f"🎯 현재 카운트: {balls}B {strikes}S {outs}O",
*defense_lines,
*runner_lines,
],
)
wait_for_operator_control(page)
set_pitch_meta_only(page, last_pitch)
if write_events:
page.wait_for_timeout(120)
simple_terminal_result = is_simple_terminal_result_type(result.get("type") or "")
expected_batter_event = infer_batter_result_label(result, event) or ""
if expected_batter_event:
for _ in range(5):
set_batter_result_type(page, result, event)
page.wait_for_timeout(50)
if get_checked_event_name(page, "evt_batter") == expected_batter_event:
break
else:
set_batter_result_type(page, result, event)
page.wait_for_timeout(50)
popup_defense_used = False
if not simple_terminal_result:
if result.get("type") in {"reach_on_error", "double_play"} or get_checked_batter_defense_type(page):
set_hit_ball_and_defense(page, event)
popup_defense_used = True
set_batter_advancement(page, result)
current_late = []
all_runner_events = (event.get("runnerEvents") or []).copy()
if last_pitch.get("runnerEvents"):
all_runner_events.extend(last_pitch["runnerEvents"])
if all_runner_events:
current_late.extend(set_runner_events(page, event, all_runner_events))
page.wait_for_timeout(30)
submit_input_complete(
page, f"{event.get('batter') or '-'} / {result_text or '-'}", clear_defense=not popup_defense_used,
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": (last_pitch.get("pitchNo", 0) if isinstance(last_pitch, dict) else 0), "target_value": result_text, "selected_value": expected_batter_event} if job_id else None
)
if current_late:
handle_late_runner_events(page, event, current_late, True, job_id)
result_type = result.get("type")
result_text = (result.get("text") or "").strip()
if result_type == "double_play":
outs += 2
if outs >= 3:
break
elif "낫아웃" in result_text and not any(token in result_text for token in ("폭투", "포일", "진루", "출루", "세이프")):
outs += 1
if outs >= 3:
break
elif result_type in {"out", "strikeout", "sacrifice_fly", "sacrifice_bunt", "single_runner_out", "double_runner_out", "triple_runner_out"}:
outs += 1
if outs >= 3:
break
def run(playwright: Playwright, args: argparse.Namespace, report: dict[str, Any]) -> None:
browser = launch_browser_context(playwright, args.user_data_dir, args.channel, args.headless)
page = browser.pages[0] if browser.pages else browser.new_page()
job_id = getattr(args, "job_id", None) or os.environ.get("JOB_ID")
if args.write_events and not job_id:
try:
import db_logging
import uuid
job_id = f"standalone-{uuid.uuid4().hex[:8]}"
db_logging.start_job(job_id, args.game_id, getattr(args, "start_inning", ""), getattr(args, "end_inning", ""))
except Exception:
job_id = None
try:
open_game_status_page(page, args.base_url, report, args.manager_game_no)
if getattr(args, "review_only", False):
process_only_reviews(page, report, args.write_events, job_id=job_id)
else:
process_report(page, report, args.write_events, job_id=job_id)
if not args.close:
page.wait_for_timeout(3600 * 1000)
finally:
if args.write_events and job_id:
try:
import db_logging
db_logging.finish_job(job_id)
except Exception:
pass
if args.close:
try:
browser.close()
except Exception:
pass

View File

@@ -0,0 +1,29 @@
# ─────────────────────────────────────────────────
# crawler_constants.yaml
# 네이버 API 크롤러 상수
# ─────────────────────────────────────────────────
# HTTP 요청 헤더
headers:
User-Agent: "Mozilla/5.0"
Accept: "application/json, text/plain, */*"
Accept-Language: "ko-KR,ko;q=0.9"
Origin: "https://m.sports.naver.com"
x-sports-backend: "kotlin"
# 무시할 textOption type 코드
skip_option_types: [0, 8, 98, 99]
# 무시할 이벤트 텍스트
hidden_event_texts:
- "투수 투수판 이탈"
- "코칭스태프 마운드 방문"
- "포수 마운드 방문"
# 교체 키워드 (텍스트에 이것이 포함되면 교체 이벤트로 판단)
change_keywords:
- "(으)로 교체"
- "수비위치 변경"
# 최대 이닝 수
max_inning: 20

View File

@@ -0,0 +1,39 @@
# ─────────────────────────────────────────────────
# field_coordinates.yaml
# 타구 좌표, 타구종류, 파울 좌표
# ─────────────────────────────────────────────────
# 타구 좌표 (zone → x, y 퍼센트)
field_coordinates:
투수: [50, 80]
포수: [50, 93]
1루수: [63, 77]
2루수: [60, 65]
3루수: [37, 77]
유격수: [40, 65]
좌익수: [22, 42]
중견수: [50, 24]
우익수: [78, 42]
좌전: [30, 50]
중전: [50, 35]
우전: [70, 50]
좌중간: [34, 34]
우중간: [66, 34]
좌월: [20, 30]
중월: [50, 14]
우월: [80, 30]
# 타구 종류 매핑 (라벨 → value)
hit_ball_type:
땅볼: "0"
일반바운드: "1"
플라이: "2"
라인드라이브: "3"
펜스타구: "4"
홈런성타구: "5"
번트타구: "6"
# 파울 플라이 기준 좌표
foul_fly:
left: [2, 70]
right: [98, 70]

83
config/mappings.yaml Normal file
View File

@@ -0,0 +1,83 @@
# ─────────────────────────────────────────────────
# mappings.yaml
# 관리자 사이트 허용값(key) → 매핑되는 네이버/입력 표기(aliases)
#
# 구조: site_label: [alias_1, alias_2, ...]
# 매핑 방향: alias → site_label (역매핑으로 조회)
# ─────────────────────────────────────────────────
# 팀명 (관리자 사이트에서 사용하는 팀 표기)
team_name:
Hero: [키움, 키움 히어로즈, Hero]
# 나머지 팀은 네이버 표기와 사이트 표기가 동일하여 별도 매핑 불필요
# 팀 코드 → 한글 팀명 (네이버 API gameId 파싱용)
team_code:
한화: [HH]
KIA: [HT]
KT: [KT]
LG: [LG]
롯데: [LT]
NC: [NC]
두산: [OB]
SSG: [SK]
삼성: [SS]
키움: [WO]
# 구장명 (관리자 사이트 select 옵션 라벨)
stadium_name:
고척돔: [고척, 고척스카이돔]
잠실: [잠실, 잠실야구장]
대구라팍: [대구 삼성 라이온즈 파크, 대구라이온즈파크, 대구 라팍, 대구삼성라이온즈파크]
수원: [수원 케이티 위즈 파크, 수원KT위즈파크, 수원kt위즈파크]
창원: [창원NC파크, 창원 nc 파크, 창원 NC 파크]
대전: [대전 한화생명 볼파크, 대전한화생명볼파크]
"한밭(~2024)": [대전 한화생명 이글스파크, 대전한화생명이글스파크]
문학: [인천, 인천 SSG 랜더스필드, 인천SSG랜더스필드, 문학]
광주: [광주-기아 챔피언스 필드, 광주 기아 챔피언스 필드, 광주KIA챔피언스필드, 광주 kia 챔피언스 필드]
사직: [사직야구장, 사직]
울산: [울산문수야구장, 울산 문수야구장, 울산]
포항: [포항야구장, 포항]
마산: [마산야구장, 마산]
군산: [군산월명야구장, 군산]
청주: [청주야구장, 청주]
목동: [목동야구장, 목동]
무등: [무등야구장, 무등]
대구: [대구시민야구장, 대구]
# 경기 유형 (관리자 사이트 select 옵션)
game_type:
정규경기: [kbo_r]
와일드카드: [wildcard, wc]
와일드카드 결정전: [와일드카드]
준플레이오프: [semi_playoff, semi_po]
플레이오프: [playoff, po]
한국시리즈: [korean_series, ks]
# 포지션 번호 (관리자 사이트 defense_no)
position_number:
"1": [투수]
"2": [포수]
"3": [1루수]
"4": [2루수]
"5": [3루수]
"6": [유격수]
"7": [좌익수]
"8": [중견수]
"9": [우익수]
"10": [지명타자]
# KBO 시즌 ID 후보 (경기 타입별)
kbo_sr_id_candidates:
정규경기: ["0", "1", "2", "3", "4", "5", "7", "8", "9"]
와일드카드: ["3", "0", "1", "2", "4", "5", "7", "8", "9"]
준플레이오프: ["4", "0", "1", "2", "3", "5", "7", "8", "9"]
플레이오프: ["5", "0", "1", "2", "3", "4", "7", "8", "9"]
한국시리즈: ["7", "0", "1", "2", "3", "4", "5", "8", "9"]
# 투수 결과 라벨
result_labels:
승리투수: [W]
패전투수: [L]
홀드: [H]
세이브: [S]

76
config/pitch_rules.yaml Normal file
View File

@@ -0,0 +1,76 @@
# ─────────────────────────────────────────────────
# pitch_rules.yaml
# 관리자 사이트 허용값(key) → 매핑되는 네이버 표기(aliases)
#
# 구조: site_label: [naver_alias_1, naver_alias_2, ...]
# 매핑 방향: 네이버 alias → site_label (역매핑으로 조회)
# ─────────────────────────────────────────────────
# 구종 (관리자 사이트 evt_ballType 옵션)
pitch_type:
패스트볼: [직구, 패스트볼]
커브: [커브]
체인지업: [체인지업]
슬라이더: [슬라이더]
커터: [커터]
스플리터: [스플리터]
너클: [너클]
폭투: [폭투]
투심: [투심]
싱커: [싱커]
포크볼: [포크, 포크볼]
기타: [] # 매핑되지 않는 구종의 폴백
# 투구 결과 (관리자 사이트 evt_batter 투구결과 영역)
pitch_result:
: [볼]
"스트라이크(루킹)": [스트라이크]
"헛스윙(스트라이크)": [헛스윙]
번트시도-스트라이크: [헛스윙 번트, 번트 헛스윙, 번트헛스윙]
파울: [파울]
번트-파울: [번트파울]
몸에 맞는 볼: [몸에 맞는 볼, 몸에 맞는 공, 사구]
고의사구: [고의사구, 자동 고의사구]
폭투-볼: [폭투-볼]
포일-볼: [포일-볼]
보크: [보크]
보크-볼: [보크-볼]
노카운트: [노카운트]
# 타자 결과 (관리자 사이트 evt_batter 타자결과 영역)
batter_result:
# ── 세이프 ──
1루타: [single]
2루타: [double]
3루타: [triple]
홈런: [home_run]
포볼: [walk]
고의사구: [intentional_walk]
몸에 맞는 볼: [hit_by_pitch]
번트안타: [bunt_hit]
수비실책: [reach_on_error]
야수선택: [reach_on_fielder_choice]
"땅볼출루(무안타)": [reach_on_grounder]
"1루타 후 주루아웃": [single_runner_out]
"2루타 후 주루아웃": [double_runner_out]
"3루타 후 주루아웃": [triple_runner_out]
"1루타 후 수비실책진루": [single_error_advance]
"2루타 후 수비실책진루": [double_error_advance]
"3루타 후 수비실책진루": [triple_error_advance]
# ── 아웃 ──
"루킹스트라이크-아웃": [strikeout]
번트-삼진: [bunt_strikeout]
아웃: [out]
희생 플라이: [sacrifice_fly]
희생 번트: [sacrifice_bunt]
# 주루 이벤트 (관리자 사이트 evt_runner_N)
runner_event:
일반 진루: [advance, score]
도루성공: [steal]
도루시도 아웃: [steal_fail]
포스아웃: [force_out]
견제 아웃: [pickoff_out]
수비 실책: [error_advance]
폭투-진루성공: [wild_pitch_advance]
포일-진루성공: [passed_ball_advance]

32
config/review_rules.yaml Normal file
View File

@@ -0,0 +1,32 @@
# ─────────────────────────────────────────────────
# review_rules.yaml
# 합의판정 항목 → 결과 그룹 매핑
# ─────────────────────────────────────────────────
# 합의판정 항목별 결과 그룹 (game_report.py + record_game_playwright.py 통합)
# type1 = 페어/파울, type2 = 아웃/세이프, type3 = 인정/불인정
review_result_groups:
홈런타구 페어 파울:
type: type1
options: [페어, 파울]
외야타구 페어 파울:
type: type1
options: [페어, 파울]
포수/태그플레이 아웃/세이프:
type: type2
options: [아웃, 세이프]
야수의 포구:
type: type2
options: [아웃, 세이프]
몸에 맞는 공:
type: type3
options: [인정, 불인정]
파울:
type: type3
options: [인정, 불인정]
헛스윙:
type: type3
options: [인정, 불인정]
기타:
type: type3
options: [인정, 불인정]

View File

@@ -0,0 +1,29 @@
# ─────────────────────────────────────────────────
# site_selectors.yaml
# 관리자 사이트 CSS 셀렉터 및 수비 버튼 매핑
# ─────────────────────────────────────────────────
# 수비 버튼 CSS 셀렉터 (포지션 → selector)
defense_button_id:
투수: "input[name='defenseNumberBtn']#picher"
포수: "input[name='defenseNumberBtn']#catcher"
1루수: "input[name='defenseNumberBtn']#runner_1"
2루수: "input[name='defenseNumberBtn']#runner_2"
3루수: "input[name='defenseNumberBtn']#runner_3"
유격수: "input[name='defenseNumberBtn']#shortStop"
중견수: "input[name='defenseNumberBtn']#centerFielder"
우익수: "input[name='defenseNumberBtn']#rightFielder"
좌익수: "input[name='defenseNumberBtn']#leftFielder"
# 포지션 → 수비번호 (사이트 defense_no select 옵션 value)
position_to_defense_no:
투수: "1"
포수: "2"
1루수: "3"
2루수: "4"
3루수: "5"
유격수: "6"
좌익수: "7"
중견수: "8"
우익수: "9"
지명타자: "10"

6
core/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
core/ — 순수 비즈니스 로직 패키지
Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다.
모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다.
"""

85
core/change_parser.py Normal file
View File

@@ -0,0 +1,85 @@
"""
core/change_parser.py — 선수 교체 이벤트 파싱
교체 텍스트에서 선수명, 포지션, 교체 유형 등을 추출합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import position_to_defense_no
def extract_change_actor(text: str) -> tuple[str | None, int | None, str]:
"""교체 텍스트의 왼쪽(actor)에서 역할, 타순, 이름 추출
'5번타자 문보경' → ('batter', 5, '문보경')
'투수 임찬규' → ('투수', None, '임찬규')
"""
lhs = (text or "").split(" : ", 1)[0].strip()
batter_match = re.search(r"(\d+)번타자\s+(.+)$", lhs)
if batter_match:
return "batter", int(batter_match.group(1)), batter_match.group(2).strip()
roles = (
"대타", "대주자",
"1루주자", "2루주자", "3루주자", "주자",
"투수", "포수", "1루수", "2루수", "3루수",
"유격수", "좌익수", "중견수", "우익수",
)
for role in roles:
if lhs.startswith(role + " "):
return role, None, lhs[len(role):].strip()
return None, None, lhs
def is_merged_pitcher_substitution(actor_role: str | None, in_role: str | None) -> bool:
"""야수→투수 교체인지 확인 (투수가 DH로 전환되는 병합 교체)"""
field_roles = {"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"}
return actor_role in field_roles and in_role == "투수"
def normalize_change_event(change_event: dict[str, Any]) -> dict[str, Any]:
"""교체 이벤트를 정규화
텍스트 파싱 → actor_name, out_player, in_player, change_type 등 추출
"""
if change_event.get("actor_name") or change_event.get("player_name"):
return change_event
text = change_event.get("text") or ""
normalized = dict(change_event)
normalized["change_type"] = "position_change" if "수비위치 변경" in text else "substitution"
actor_role, bat_order, actor_name = extract_change_actor(text)
normalized["actor_role"] = actor_role
normalized["actor_name"] = actor_name
if bat_order is not None:
normalized["bat_order"] = bat_order
if normalized["change_type"] == "position_change":
rhs = text.split(" : ", 1)[1] if " : " in text else ""
normalized["player_name"] = actor_name
normalized["to_position"] = rhs.split("(으)로", 1)[0].strip()
return normalized
rhs = text.split(" : ", 1)[1] if " : " in text else ""
rhs = rhs.split("(으)로 교체", 1)[0].strip()
in_role, _, in_name = extract_change_actor(rhs)
normalized["out_player"] = actor_name
normalized["in_player"] = in_name
normalized["in_role"] = in_role
pos_defense = position_to_defense_no()
if is_merged_pitcher_substitution(actor_role, in_role):
normalized["change_type"] = "merged_pitcher_substitution"
normalized["player_name"] = actor_name
normalized["to_position"] = "지명타자"
normalized["pitcher_in_player"] = in_name
return normalized
if in_role in pos_defense:
normalized["to_position"] = in_role
return normalized

195
core/config_loader.py Normal file
View File

@@ -0,0 +1,195 @@
"""
config_loader.py — YAML 설정 파일 로딩 + 캐싱
모든 설정 접근의 단일 진입점.
config/ 폴더의 YAML 파일을 로드하고 lru_cache로 캐싱합니다.
YAML 구조: site_label(key) → [alias_1, alias_2, ...] (Closed Set 기반)
조회 시: alias → site_label (역매핑)
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
@lru_cache(maxsize=None)
def load_config(name: str) -> dict[str, Any]:
"""YAML 파일을 로드하여 dict로 반환 (결과 캐싱)"""
path = CONFIG_DIR / f"{name}.yaml"
if not path.exists():
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {path}")
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data
def get_mapping(config_name: str, key: str) -> dict[str, Any]:
"""특정 설정 파일의 특정 섹션을 반환 (원본 구조 그대로)"""
return load_config(config_name).get(key, {})
def get_list(config_name: str, key: str) -> list:
"""특정 설정 파일의 특정 리스트 섹션을 반환"""
return load_config(config_name).get(key, [])
def get_value(config_name: str, key: str, default: Any = None) -> Any:
"""특정 설정 파일의 단일 값을 반환"""
return load_config(config_name).get(key, default)
# ──────────────────────────────────────────────
# Closed Set 역매핑 빌드
# ──────────────────────────────────────────────
@lru_cache(maxsize=None)
def _build_reverse_map(config_name: str, key: str) -> dict[str, str]:
"""site_label: [aliases...] 구조를 {alias: site_label} 역매핑으로 변환
예: { '패스트볼': ['직구', '패스트볼'] }
{ '직구': '패스트볼', '패스트볼': '패스트볼' }
"""
raw = get_mapping(config_name, key)
reverse: dict[str, str] = {}
for site_label, aliases in raw.items():
if isinstance(aliases, list):
for alias in aliases:
reverse[str(alias)] = str(site_label)
else:
# aliases가 리스트가 아닌 경우 (단순 값이면 그대로)
reverse[str(aliases)] = str(site_label)
return reverse
def allowed_values(config_name: str, key: str) -> set[str]:
"""해당 섹션의 관리자 사이트 허용값(Closed Set) 반환"""
raw = get_mapping(config_name, key)
return set(raw.keys())
def lookup(config_name: str, key: str, alias: str) -> str | None:
"""alias → site_label 조회. 없으면 None"""
return _build_reverse_map(config_name, key).get(alias)
def lookup_or_raise(config_name: str, key: str, alias: str) -> str:
"""alias → site_label 조회. 없으면 오류"""
result = lookup(config_name, key, alias)
if result is None:
allowed = allowed_values(config_name, key)
raise ValueError(
f"매핑 오류: '{alias}'{key}의 허용값에 없습니다. "
f"허용값: {sorted(allowed)}"
)
return result
# ──────────────────────────────────────────────
# 편의 함수: 역매핑 (alias → site_label)
# ──────────────────────────────────────────────
def pitch_type_map() -> dict[str, str]:
"""네이버 stuff → 사이트 구종 라벨"""
return _build_reverse_map("pitch_rules", "pitch_type")
def pitch_result_map() -> dict[str, str]:
"""네이버 pitchResultText → 사이트 투구결과 라벨"""
return _build_reverse_map("pitch_rules", "pitch_result")
def batter_result_map() -> dict[str, str]:
"""result.type → 사이트 타자결과 라벨"""
return _build_reverse_map("pitch_rules", "batter_result")
def runner_event_map() -> dict[str, str]:
"""runnerEvent.type → 사이트 주루 라벨"""
return _build_reverse_map("pitch_rules", "runner_event")
def team_name_map() -> dict[str, str]:
"""네이버 팀명 → 사이트 팀명"""
return _build_reverse_map("mappings", "team_name")
def team_code_map() -> dict[str, str]:
"""네이버 팀코드 → 한글 팀명"""
return _build_reverse_map("mappings", "team_code")
def stadium_name_map() -> dict[str, str]:
"""네이버 구장명 → 사이트 구장명"""
return _build_reverse_map("mappings", "stadium_name")
def game_type_map() -> dict[str, str]:
"""네이버 경기유형 → 사이트 경기유형"""
return _build_reverse_map("mappings", "game_type")
def position_number_map() -> dict[str, str]:
"""포지션명 → 번호"""
return _build_reverse_map("mappings", "position_number")
def result_labels() -> dict[str, str]:
"""W/L/H/S → 승리투수/패전투수/홀드/세이브"""
return _build_reverse_map("mappings", "result_labels")
def kbo_sr_id_candidates() -> dict[str, list]:
"""역매핑 불필요 — 원본 그대로"""
return get_mapping("mappings", "kbo_sr_id_candidates")
# ──────────────────────────────────────────────
# 편의 함수: Closed Set 직접 조회
# ──────────────────────────────────────────────
def pitch_type_allowed() -> set[str]:
return allowed_values("pitch_rules", "pitch_type")
def pitch_result_allowed() -> set[str]:
return allowed_values("pitch_rules", "pitch_result")
def batter_result_allowed() -> set[str]:
return allowed_values("pitch_rules", "batter_result")
def runner_event_allowed() -> set[str]:
return allowed_values("pitch_rules", "runner_event")
# ──────────────────────────────────────────────
# 편의 함수: 역매핑 불필요한 것들 (원본 구조 그대로)
# ──────────────────────────────────────────────
def field_coordinates() -> dict[str, list]:
return get_mapping("field_coordinates", "field_coordinates")
def hit_ball_type_map() -> dict[str, str]:
return get_mapping("field_coordinates", "hit_ball_type")
def foul_fly_coords() -> dict[str, list]:
return get_mapping("field_coordinates", "foul_fly")
def defense_button_id_map() -> dict[str, str]:
return get_mapping("site_selectors", "defense_button_id")
def position_to_defense_no() -> dict[str, str]:
return get_mapping("site_selectors", "position_to_defense_no")
def review_result_groups() -> dict[str, dict]:
return get_mapping("review_rules", "review_result_groups")
def crawler_headers() -> dict[str, str]:
return get_mapping("crawler_constants", "headers")
def skip_option_types() -> set[int]:
return set(get_list("crawler_constants", "skip_option_types"))
def hidden_event_texts() -> set[str]:
return set(get_list("crawler_constants", "hidden_event_texts"))
def change_keywords() -> tuple[str, ...]:
return tuple(get_list("crawler_constants", "change_keywords"))
def max_inning() -> int:
return get_value("crawler_constants", "max_inning", 20)

255
core/field_calculator.py Normal file
View File

@@ -0,0 +1,255 @@
"""
core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산
필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음.
"""
from __future__ import annotations
import math
import re
from typing import Any
from core.config_loader import (
field_coordinates,
hit_ball_type_map,
foul_fly_coords,
position_number_map,
)
# ──────────────────────────────────────────────
# 타구 종류 추론
# ──────────────────────────────────────────────
def infer_hit_ball_type(result_text: str) -> str:
"""결과 텍스트에서 타구 종류 추론
'2루수 땅볼 아웃''땅볼'
'좌익수 뒤 2루타''일반바운드'
"""
if "번트" in result_text:
return "번트타구"
if "몸에 맞는 타구" in result_text:
return "땅볼"
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
return "플라이"
if "파울플라이" in result_text:
return "플라이"
if "라인드라이브" in result_text or "직선타" in result_text:
return "라인드라이브"
if "플라이" in result_text:
return "플라이"
if "땅볼" in result_text:
return "땅볼"
if "홈런" in result_text:
return "홈런성타구"
return "일반바운드"
def get_hit_ball_type_code(hit_ball_type: str) -> str:
"""타구 종류 라벨 → 사이트 value 코드"""
return hit_ball_type_map().get(hit_ball_type, "1")
# ──────────────────────────────────────────────
# 필드 존 추론
# ──────────────────────────────────────────────
ORDERED_ZONES = (
"좌중간", "우중간",
"좌전", "중전", "우전",
"좌월", "중월", "우월",
"좌익수", "중견수", "우익수",
"유격수", "3루수", "2루수", "1루수",
"투수", "포수",
)
def infer_field_zone(result_text: str) -> str:
"""결과 텍스트에서 타구 방향(zone) 추론
'우익수 앞 1루타''우익수'
"""
if "몸에 맞는 타구" in result_text:
return "1루수"
for zone in ORDERED_ZONES:
if zone in result_text:
return zone
return "중견수"
def extract_direction_offsets(result_text: str) -> tuple[int, int]:
"""결과 텍스트에서 방향 오프셋 추출
'좌익수 왼쪽 뒤' → (-1, -1)
"""
x_delta = 0
y_delta = 0
if "왼쪽" in result_text:
x_delta -= 1
if "오른쪽" in result_text:
x_delta += 1
if "" in result_text:
y_delta += 1
if "" in result_text:
y_delta -= 1
return x_delta, y_delta
def is_infield_zone(zone: str) -> bool:
"""내야 존인지 확인"""
return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"}
# ──────────────────────────────────────────────
# 좌표 계산
# ──────────────────────────────────────────────
def get_zone_coordinates(zone: str) -> tuple[int, int]:
"""존 이름 → (x, y) 퍼센트 좌표"""
coords = field_coordinates()
coord = coords.get(zone, coords.get("중견수", [50, 24]))
return tuple(coord)
def get_foul_fly_coordinates(side: str) -> tuple[int, int]:
"""파울 플라이 좌표 ('left' 또는 'right')"""
coords = foul_fly_coords()
return tuple(coords.get(side, [50, 70]))
def calculate_hit_ball_coordinates(
result_text: str,
zone: str | None = None,
) -> tuple[int, int]:
"""결과 텍스트로부터 타구 좌표 계산
Returns: (x, y) 퍼센트 좌표
"""
if zone is None:
zone = infer_field_zone(result_text)
x, y = get_zone_coordinates(zone)
x_delta, y_delta = extract_direction_offsets(result_text)
infield = is_infield_zone(zone)
step = 3 if infield else 5
x += x_delta * step
y += y_delta * step
# 범위 제한
x = max(0, min(100, x))
y = max(0, min(100, y))
return x, y
def calculate_distance(x: int, y: int, meter_per_px: float) -> float:
"""좌표에서 홈까지의 거리 계산 (미터)"""
home_x, home_y = 50, 93
dx = (x - home_x) * meter_per_px
dy = (y - home_y) * meter_per_px
return math.sqrt(dx * dx + dy * dy)
# ──────────────────────────────────────────────
# 수비 시퀀스 추출
# ──────────────────────────────────────────────
def _position_label_map() -> dict[str, str]:
"""번호 → 포지션명 역매핑"""
return {v: k for k, v in position_number_map().items()}
def extract_defense_sequence(result_text: str) -> list[str]:
"""결과 텍스트에서 수비 시퀀스 추출
'2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수']
"""
pos_label = _position_label_map()
# 1) '2-6', '2-5-3' 같은 숫자 패턴
num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text)
if num_seq_match:
nums = num_seq_match.group(1).split("-")
pos_names = [pos_label[n] for n in nums if n in pos_label]
if pos_names:
return pos_names
# 2) 괄호 안에서 포지션 추출
parenthetical_match = re.search(r"\(([^)]*)\)", result_text)
if parenthetical_match:
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
parenthetical_match.group(1),
)
if sequence:
return sequence
# 3) 괄호 앞 본문에서 포지션 추출
leading_text = result_text.split("(", 1)[0]
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
leading_text,
)
if sequence:
return sequence
# 4) 존에서 폴백
zone = infer_field_zone(result_text)
pos_num = position_number_map()
if zone in pos_num:
return [zone]
return []
def extract_error_position(result_text: str) -> str | None:
"""실책 관련 텍스트에서 실책 수비자 포지션 추출"""
parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text)
search_texts = [parenthetical_match.group(1)] if parenthetical_match else []
search_texts.append(result_text)
for text in search_texts:
positions = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
text,
)
if positions:
return positions[0]
return None
def infer_error_position_fallback(text: str) -> str:
"""실책 포지션 추론 폴백"""
if "야수선택" in text:
return "야수선택"
if "도루" in text:
return "포수"
if "포구" in text:
return "포수"
if "송구" in text:
return "투수"
return "포수"
def is_error_result(result_text: str) -> bool:
"""실책 결과인지 확인"""
return "실책" in result_text
def is_throwing_error(result_text: str) -> bool:
"""송구 실책인지 확인"""
keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러")
return any(keyword in result_text for keyword in keywords)
def is_double_play_result(result_text: str) -> bool:
"""병살인지 확인"""
return "병살" in result_text
def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]:
"""병살 이벤트의 첫 번째 수비 시퀀스"""
result_text = ((event.get("result") or {}).get("text") or "").strip()
return extract_defense_sequence(result_text)

150
core/normalizer.py Normal file
View File

@@ -0,0 +1,150 @@
"""
core/normalizer.py — 모든 정규화 함수의 단일 진입점
팀명, 구장, 포지션, 선수명, 경기유형 등의 정규화를 담당합니다.
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import (
team_name_map,
team_code_map,
stadium_name_map,
game_type_map,
position_number_map,
position_to_defense_no,
)
# ──────────────────────────────────────────────
# 팀/구장/경기유형 정규화
# ──────────────────────────────────────────────
def normalize_team_name(name: str) -> str:
"""팀명 정규화 (네이버 표기 → 관리자 사이트 표기)"""
return team_name_map().get(name, name)
def normalize_team_code(code: str) -> str:
"""팀 코드 → 한글 팀명"""
return team_code_map().get(code, code)
def normalize_game_type(name: str) -> str:
"""경기 유형 정규화"""
return game_type_map().get(name, name)
def normalize_stadium_name(name: str) -> str:
"""구장명 정규화 (네이버 표기 → 관리자 사이트 select 라벨)"""
return stadium_name_map().get(name, name)
def normalize_position_to_number(position: str) -> str:
"""포지션명 → 번호 문자열 (투수→1, 포수→2, ...)"""
return position_number_map().get(position, "")
def normalize_position_to_defense_no(position: str) -> str:
"""포지션명 → 수비번호 (라인업 select 옵션 value)"""
return position_to_defense_no().get(position, "")
def position_label_from_number(number: str) -> str:
"""수비번호 → 포지션명 (역매핑)"""
pos_map = position_number_map()
reverse = {v: k for k, v in pos_map.items()}
return reverse.get(number, "")
# ──────────────────────────────────────────────
# 선수명/번호 정규화
# ──────────────────────────────────────────────
def normalize_player_name(name: str | None) -> str:
"""선수명 정규화: *, 괄호 내용 제거"""
text = (name or "").replace("*", "").strip()
text = re.sub(r"\([^)]*\)\s*$", "", text).strip()
return text
def normalize_lineup_text(text: str) -> str:
"""라인업 텍스트에서 순수 이름만 추출
'[10] 문보경' / '문보경 [10번]' 등 → '문보경'
"""
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 normalize_number_text(number: str | int | None) -> str:
"""등번호 정규화: 숫자만 추출"""
text = str(number or "").strip()
digits = "".join(char for char in text if char.isdigit())
if not digits:
return ""
return str(int(digits))
def normalize_option_player_text(text: str) -> tuple[str, str]:
"""select option 텍스트에서 선수명과 번호 분리
'문보경 [10번]' → ('문보경', '10')
"""
stripped = " ".join(text.split())
matched = re.match(r"^(.*?)\s*\[(\d+)번\]$", stripped)
if matched:
return normalize_player_name(matched.group(1)), normalize_number_text(matched.group(2))
return normalize_player_name(stripped), ""
# ──────────────────────────────────────────────
# 시간 유틸
# ──────────────────────────────────────────────
def split_time(iso_time: str | None) -> tuple[str, str]:
"""ISO 시간 문자열에서 시/분 분리
'2026-04-14T18:30:00' → ('18', '30')
"""
if not iso_time:
return "00", "00"
from datetime import datetime
dt = datetime.fromisoformat(iso_time)
return f"{dt.hour:02d}", f"{dt.minute:02d}"
# ──────────────────────────────────────────────
# 텍스트 추론 유틸
# ──────────────────────────────────────────────
def infer_option_role_hint(text: str) -> str:
"""select option 텍스트에서 역할 힌트 추출
'문보경 (투) [10번]''pitcher'
'문보경 (타)''batter'
"""
stripped = " ".join(text.split())
matched = re.search(r"\(([^)]*)\)\s*(?:\[\d+번\])?$", stripped)
if not matched:
return ""
hint = matched.group(1).strip()
if hint == "":
return "pitcher"
if hint == "":
return "batter"
return ""
def infer_target_role_hint(position_name: str | None) -> str:
"""포지션명에서 역할 힌트 추론"""
if position_name == "투수":
return "pitcher"
return "batter"

273
core/pitch_classifier.py Normal file
View File

@@ -0,0 +1,273 @@
"""
core/pitch_classifier.py — 투구/타자 결과 분류
네이버 리포트 데이터를 기반으로 관리자 사이트에서 선택해야 할
라디오 버튼의 라벨(eventName)을 결정합니다.
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import (
pitch_type_map,
pitch_result_map,
batter_result_map,
)
# ──────────────────────────────────────────────
# 구종 분류
# ──────────────────────────────────────────────
def classify_pitch_type(pitch_type_text: str) -> str | None:
"""네이버 구종 텍스트 → 사이트 구종 라벨
예: '직구''패스트볼', '포크''포크볼'
"""
return pitch_type_map().get(pitch_type_text or "")
# ──────────────────────────────────────────────
# 투구 결과 분류
# ──────────────────────────────────────────────
def classify_pitch_result(pitch_result_text: str) -> str | None:
"""네이버 투구결과 텍스트 → 사이트 투구결과 라벨
예: '''', '스트라이크''스트라이크(루킹)'
"""
return pitch_result_map().get(pitch_result_text or "")
def normalize_pitch_result_code(pitch: dict[str, Any], event: dict[str, Any] | None = None) -> str:
"""투구의 pitchResult 코드를 정규화
피치클락, 번트헛스윙, 폭투/포일 등 특수 상황 처리
"""
pitch_result = (pitch.get("pitchResult") or "").strip()
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
normalized_text = pitch_result_text.replace(" ", "")
# 피치클락 투수위반 → 볼
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
return "B"
# 번트 헛스윙 → BS
if "번트" in normalized_text and "헛스윙" in normalized_text:
return "BS"
# 폭투/포일 진루 시 → 볼
runner_events = _get_pitch_runner_events(pitch, event)
if any(
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
for re_ in runner_events
):
return "B"
if any(
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
for re_ in runner_events
):
return "B"
return pitch_result
# ──────────────────────────────────────────────
# 타자 결과 분류
# ──────────────────────────────────────────────
def classify_batter_result(result_type: str) -> str | None:
"""결과 타입 코드 → 사이트 타자결과 라벨 (기본 매핑)
더 복잡한 추론이 필요한 경우 infer_batter_result_label 사용.
"""
return batter_result_map().get(result_type or "")
def infer_batter_result_label(
result: dict[str, Any],
event: dict[str, Any] | None = None,
) -> str | None:
"""타석 결과를 종합적으로 추론하여 사이트 라벨 반환
result.type, result.text, 주루이벤트, 마지막 투구 등을 모두 분석.
"""
result_type = result.get("type") or ""
result_text = (result.get("text") or "").strip()
runner_events = (event or {}).get("runnerEvents") or []
last_pitch_result_text = get_last_pitch_result_text(event)
# 낫아웃
if result_type == "strikeout_not_out" or "낫아웃" in result_text:
if "폭투" in result_text:
return "폭투 낫아웃 진루"
if "포일" in result_text:
return "포일 낫아웃 진루"
if "아웃" in result_text:
return "스트라이크-낫아웃"
return "낫아웃-출루"
# 삼진
if result_type == "strikeout":
if "헛스윙" in last_pitch_result_text or "헛스윙" in result_text:
return "스윙 스트라이크-아웃"
return "루킹스트라이크-아웃"
# 희생 번트
if "희생 번트" in result_text or "희생번트" in result_text:
return "희생 번트"
# 번트 아웃
if "번트 아웃" in result_text or "번트아웃" in result_text:
return "번트-아웃"
# 보크
if any(
"보크" in (re_.get("text") or "") and "진루" in (re_.get("text") or "")
for re_ in runner_events
):
if "" in last_pitch_result_text:
return "보크-볼"
return "보크"
# 폭투-볼
if any(re_.get("type") == "wild_pitch_advance" for re_ in runner_events):
return "폭투-볼"
# 포볼
if result_type == "walk":
if any(
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
for re_ in runner_events
):
return "폭투-포볼"
if any(
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
for re_ in runner_events
):
return "포일-포볼"
return "포볼"
# 포일-볼/스트라이크
if any(
(re_.get("type") or "") == "passed_ball_advance"
for re_ in runner_events
):
if "" in last_pitch_result_text:
return "포일-볼"
return "포일-스트라이크"
# 수비실책
if result_type == "reach_on_error" or "실책" in result_text:
return "수비실책"
# 야수선택
if result_type == "reach_on_fielder_choice":
return "야수선택"
# 땅볼출루
if result_type == "reach_on_grounder":
return "땅볼출루(무안타)"
# 병살
if result_type == "double_play":
if "번트" in result_text:
return "번트-병살"
return "병살-아웃"
# N루타 후 주루아웃
if result_type == "single_runner_out":
return "1루타 후 주루아웃"
if result_type == "double_runner_out":
return "2루타 후 주루아웃"
if result_type == "triple_runner_out":
return "3루타 후 주루아웃"
# N루타 후 수비실책진루
if result_type == "single_error_advance":
return "1루타 후 수비실책진루"
if result_type == "double_error_advance":
return "2루타 후 수비실책진루"
if result_type == "triple_error_advance":
return "3루타 후 수비실책진루"
# 파울희생플라이
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
return "희생 플라이"
# 아웃 상세
if result_type == "out":
if "병살" in result_text:
if "번트" in result_text:
return "번트-병살"
return "병살-아웃"
if "희생 플라이" in result_text or "희생플라이" in result_text:
return "희생 플라이"
if "인필드플라이" in result_text:
return "인필드플라이"
if "파울플라이" in result_text:
return "파울플라이-아웃"
return "아웃"
# 번트안타
if result_type == "bunt_hit":
return "번트안타"
# 내야안타
if result_type == "single":
if "번트안타" in result_text:
return "번트안타"
if "내야안타" in result_text:
return "내야안타"
# 몸에 맞는 볼
if result_type == "hit_by_pitch" or "헤드샷" in result_text:
return "몸에 맞는 볼"
# 기본 매핑 폴백
return classify_batter_result(result_type)
def is_simple_terminal_result_type(result_type: str) -> bool:
"""팝업 없이 즉시 완료되는 결과 타입인지 확인"""
return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"}
def is_ball_in_play_event(event: dict[str, Any]) -> bool:
"""인플레이 이벤트인지 확인 (마지막 투구가 H)"""
pitches = event.get("pitches") or []
result = event.get("result") or {}
if not pitches or not result:
return False
return pitches[-1].get("pitchResult") == "H"
# ──────────────────────────────────────────────
# 내부 헬퍼
# ──────────────────────────────────────────────
def _get_pitch_runner_events(
pitch: dict[str, Any],
event: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""투구에 연결된 주루이벤트 반환"""
if pitch.get("runnerEvents"):
return pitch["runnerEvents"]
if event:
pitch_num = pitch.get("pitchNum")
for re_ in (event.get("runnerEvents") or []):
if re_.get("pitchNum") == pitch_num:
return [re_]
return []
def get_last_pitch_result_text(event: dict[str, Any] | None) -> str:
"""이벤트의 마지막 투구 결과 텍스트 반환"""
if not event:
return ""
pitches = event.get("pitches") or []
if not pitches:
return ""
return (pitches[-1].get("pitchResultText") or "").strip()

131
core/review_parser.py Normal file
View File

@@ -0,0 +1,131 @@
"""
core/review_parser.py — 합의판정/비디오판독 파싱
판독 텍스트에서 항목, 원래 판정, 최종 판정 등을 추출합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import review_result_groups
def infer_review_item(detail_text: str) -> str:
"""판독 텍스트에서 사이트 표준 항목 추론
'홈런 파울 판정''홈런타구 페어 파울'
"""
dt = detail_text.replace(" ", "")
if "홈런" in dt:
return "홈런타구 페어 파울"
if "아웃" in dt or "세이프" in dt or "포스" in dt or "태그" in dt or "견제" in dt or "도루" in dt:
return "포수/태그플레이 아웃/세이프"
if "페어" in dt or "파울" in dt:
return "외야타구 페어 파울"
if "포구" in dt or "노바운드" in dt or "바운드" in dt:
return "야수의 포구"
if "몸에맞" in dt or "데드볼" in dt:
return "몸에 맞는 볼"
if "헛스윙" in dt or "스윙" in dt:
return "헛스윙"
return "기타"
def normalize_review_result_token(token: str, review_item: str) -> str | None:
"""판독 결과 토큰을 정규화
'세이프''세이프', '노스윙''불인정'
"""
token = (token or "").strip()
if not token:
return None
if review_item in {"홈런타구 페어 파울", "외야타구 페어 파울"}:
if "페어" in token:
return "페어"
if "파울" in token:
return "파울"
elif review_item in {"포수/태그플레이 아웃/세이프", "야수의 포구"}:
if "아웃" in token:
return "아웃"
if "세이프" in token:
return "세이프"
elif review_item == "헛스윙":
# '노스윙'에도 '스윙'이 포함되므로 먼저 체크
if "불인정" in token or "노스윙" in token or "공포" in token or "노 스윙" in token:
return "불인정"
if "스윙" in token or "인정" in token:
return "인정"
else:
if "불인정" in token or "실패" in token:
return "불인정"
if "인정" in token:
return "인정"
return token # 모르는 키워드 → 원문 그대로
return None
def parse_review_event_text(text: str) -> dict[str, Any]:
"""판독 텍스트를 파싱하여 구조화된 dict로 변환
입력 예: '6회초 8번타순 1구 후 18:45 ~ 18:46 (1분간) LG요청
비디오 판독: 안중열 포스아웃 관련 세이프→세이프'
"""
inning_match = re.search(r"(\d+)회(초|말)", text)
request_team_match = re.search(r"([가-힣A-Za-z]+)요청\s*(?:비디오 판독|합의 판정)", text)
# '→노 스윙' 같은 공백 정규화
normalized = re.sub(r"→([가-힣]+)\s+([가-힣]+)", r"\1\2", text)
detail_match = re.search(
r"(?:비디오 판독|합의 판정):\s*(.+?)\s*([가-힣]+)→([가-힣]+)\s*$",
normalized,
)
detail_text = detail_match.group(1).strip() if detail_match else text
review_item = infer_review_item(detail_text)
before_result = normalize_review_result_token(detail_match.group(2), review_item) if detail_match else None
after_result = normalize_review_result_token(detail_match.group(3), review_item) if detail_match else None
return {
"type": "video_review",
"text": text,
"requestInningLabel": (
f"{inning_match.group(1)}{'' if inning_match.group(2) == '' else ''}"
if inning_match else None
),
"requestTeam": request_team_match.group(1) if request_team_match else None,
"reviewItem": review_item,
"beforeResult": before_result,
"finalResult": after_result,
"isSuccess": (
"성공" if before_result and after_result and before_result != after_result
else "실패"
),
"timing": "before_pitch" if "초구 전" in text else "after_pitch",
}
def normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]:
"""판독 이벤트를 정규화
beforeResult/finalResult가 누락된 경우 텍스트에서 재파싱
"""
has_results = (
review_event.get("beforeResult") is not None
and review_event.get("finalResult") is not None
)
if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results:
return review_event
text = review_event.get("text") or ""
parsed = parse_review_event_text(text)
parsed.update({k: v for k, v in review_event.items() if k not in parsed})
return parsed
def get_review_result_group(review_item: str) -> dict[str, Any] | None:
"""사이트에서 판독항목에 대응하는 결과 그룹 정보 반환"""
groups = review_result_groups()
return groups.get(review_item)

133
core/runner_classifier.py Normal file
View File

@@ -0,0 +1,133 @@
"""
core/runner_classifier.py — 주루 이벤트 분류
네이버 리포트의 주루 이벤트를 분석하여 관리자 사이트에서
선택해야 할 라디오 버튼 라벨을 결정합니다.
"""
from __future__ import annotations
from typing import Any
from core.config_loader import runner_event_map
def classify_runner_event(event_type: str) -> str | None:
"""주루 이벤트 타입 → 사이트 라벨 (기본 매핑)"""
return runner_event_map().get(event_type or "")
def infer_runner_action_label(
event: dict[str, Any],
runner_event: dict[str, Any],
) -> str | None:
"""주루 이벤트를 종합적으로 추론하여 사이트 라벨 반환
리포트 action_label, event_type, event_text, result_type 등을 모두 분석.
"""
# 0. 리포트에 명시된 라벨이 있으면 최우선
if "action_label" in runner_event:
return runner_event["action_label"]
event_type = runner_event.get("type") or ""
event_text = runner_event.get("text") or ""
result_type = ((event.get("result") or {}).get("type") or "")
result_text = ((event.get("result") or {}).get("text") or "")
# 이중도루 실패 + 진루
if "이중도루 실패" in event_text and "진루" in event_text:
return "기타 진루"
if "도루" in event_text and "실패" in event_text and "진루" in event_text:
return "기타 진루"
# 견제 아웃
if event_type == "pickoff_out" or "견제사" in event_text:
return "견제 아웃"
# 도루 실패
if event_type == "steal_fail":
return "도루시도 아웃"
if "이중도루 실패" in event_text and "아웃" in event_text:
return "도루시도 아웃"
# 도루 + 실책 진루
if "도루" in event_text and "실책" in event_text and ("진루" in event_text or event_type == "error_advance"):
return "도루성공&실책"
# 도루
if "도루" in event_text:
if "실패" in event_text:
return "도루시도 아웃"
return "도루성공"
# 낫아웃 + 폭투/포일
if "낫아웃" in result_text and event_type == "wild_pitch_advance":
return "폭투 낫아웃 진루"
if "낫아웃" in result_text and event_type == "passed_ball_advance":
return "포일 낫아웃 진루"
# 포일 진루
if "포일" in event_text and ("진루" in event_text or event_type == "passed_ball_advance"):
return "포일-진루성공"
# 실책으로 진루
if "실책으로" in event_text:
return "수비 실책"
# 안타/아웃 상황 → 일반 진루
play_types = {
"single", "double", "triple", "home_run", "out", "strikeout",
"play", "sacrifice_fly", "sacrifice_bunt", "ground_out", "fly_out",
}
if result_type in play_types and event_type in {"advance", "score"}:
return "일반 진루"
# 볼넷 상황 → 볼넷 진루
walk_types = {"walk", "intentional_walk", "hit_by_pitch"}
if result_type in walk_types and event_type in {"advance", "score"}:
return "볼넷 진루"
# 기본: 일반 진루
if event_type in {"advance", "score"}:
return "일반 진루"
# 최종 폴백: config 매핑
return classify_runner_event(event_type)
def get_runner_area_type(event: dict[str, Any], runner_event: dict[str, Any]) -> int:
"""주루 이벤트의 입력 영역 타입 결정
1 = 진루 영역 (일반 진루, 볼넷 진루 등)
2 = 액션 영역 (도루, 견제, 폭투, 포일 등)
"""
event_text = runner_event.get("text") or ""
action_keywords = ["도루", "견제", "폭투", "포일", "태그아웃", "포스아웃"]
if any(k in event_text for k in action_keywords):
return 2
return 1
def split_complex_runner_event(
runner_event: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""복합 주루 이벤트를 두 개로 분리
예: '도루성공 후 수비실책 진루' → (도루, 실책진루)
"""
text = runner_event.get("text") or ""
if "실책" not in text and "/" not in text:
return runner_event, None
# '도루성공&실책' 같은 패턴
if "도루" in text and "실책" in text and "진루" in text:
first = dict(runner_event)
first["type"] = "steal"
first["text"] = text
second = dict(runner_event)
second["type"] = "error_advance"
second["text"] = text
return first, second
return runner_event, None

6
crawler/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
crawler/ — 네이버 스포츠 API 크롤링 패키지
네이버 API에서 데이터를 수집하고, relay 데이터를 파싱하여
정규화된 JSON 리포트를 생성합니다.
"""

116
crawler/lineup_builder.py Normal file
View File

@@ -0,0 +1,116 @@
"""
crawler/lineup_builder.py — 라인업 데이터 구성
relay 데이터와 preview 데이터에서 라인업 정보를 추출합니다.
"""
from __future__ import annotations
from typing import Any
from crawler.naver_api import get_team_names
def get_starting_pitcher(pitchers: list[dict[str, Any]]) -> dict[str, Any] | None:
"""투수 리스트에서 선발투수 추출"""
if not pitchers:
return None
return min(pitchers, key=lambda p: p.get("seqno", 999))
def get_starting_batters(batters: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""타자 리스트에서 선발 라인업 추출"""
starters_by_order: dict[int, dict[str, Any]] = {}
for batter in sorted(batters, key=lambda b: (b.get("batOrder", 999), b.get("seqno", 999))):
bat_order = batter.get("batOrder")
if bat_order is None or bat_order in starters_by_order:
continue
starters_by_order[bat_order] = batter
return [starters_by_order[order] for order in sorted(starters_by_order)]
def build_lineup_team(team_name: str, lineup: dict[str, Any]) -> dict[str, Any]:
"""relay 데이터의 라인업 → 정규화된 팀 라인업 dict"""
starter_pitcher = get_starting_pitcher(lineup.get("pitcher", []))
starting_batters = get_starting_batters(lineup.get("batter", []))
return {
"team_name": team_name,
"starter_pitcher": {
"name": starter_pitcher.get("name"),
"position": "투수",
"number": starter_pitcher.get("backnum"),
}
if starter_pitcher
else None,
"players": [
{
"bat_order": batter.get("batOrder"),
"name": batter.get("name"),
"position": batter.get("posName"),
"number": batter.get("backnum"),
}
for batter in starting_batters
],
}
def build_preview_lineup_team(
team_name: str, preview_lineup: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""preview 데이터의 라인업 → 정규화된 팀 라인업 dict"""
if not preview_lineup:
return None
full_lineup = preview_lineup.get("fullLineUp") or []
starter_pitcher = next(
(
player
for player in full_lineup
if player.get("positionName") == "선발투수"
or int(player.get("batorder", 0) or 0) == 0
),
None,
)
batters = sorted(
(player for player in full_lineup if int(player.get("batorder", 0) or 0) > 0),
key=lambda p: int(p.get("batorder", 99) or 99),
)
return {
"team_name": team_name,
"starter_pitcher": {
"name": starter_pitcher.get("playerName"),
"position": "투수",
"number": starter_pitcher.get("backnum"),
}
if starter_pitcher
else None,
"players": [
{
"bat_order": int(player.get("batorder")),
"name": player.get("playerName"),
"position": player.get("positionName"),
"number": player.get("backnum"),
}
for player in batters
],
}
def build_lineup_summary(
game_id: str,
game_info: dict[str, Any],
relay_data: dict[str, Any],
preview_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""전체 라인업 요약 생성 (preview 우선, relay 폴백)"""
away_name, home_name = get_team_names(game_id, game_info)
away_preview = build_preview_lineup_team(
away_name, (preview_data or {}).get("awayTeamLineUp"),
)
home_preview = build_preview_lineup_team(
home_name, (preview_data or {}).get("homeTeamLineUp"),
)
return {
"away_team": away_preview or build_lineup_team(away_name, relay_data["awayLineup"]),
"home_team": home_preview or build_lineup_team(home_name, relay_data["homeLineup"]),
}

197
crawler/naver_api.py Normal file
View File

@@ -0,0 +1,197 @@
"""
crawler/naver_api.py — 네이버 스포츠 API HTTP 클라이언트
모든 네이버 API 호출을 캡슐화합니다.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
import httpx
from core.config_loader import (
crawler_headers,
game_type_map,
kbo_sr_id_candidates,
result_labels,
team_code_map,
)
BASE_URL = "https://api-gw.sports.naver.com/schedule/games"
KBO_URL = "https://www.koreabaseball.com/ws/Schedule.asmx/GetScoreBoardScroll"
class NaverApiClient:
"""네이버 스포츠 API 클라이언트
httpx.Client를 래핑하여 게임 정보, relay, 라인업, 기록 등을 가져옵니다.
with 문으로 사용하세요:
with NaverApiClient() as api:
relay = api.fetch_relay(game_id)
"""
def __init__(self, timeout: float = 20.0):
self._client: httpx.Client | None = None
self._timeout = timeout
def __enter__(self) -> "NaverApiClient":
self._client = httpx.Client(headers=crawler_headers(), timeout=self._timeout)
return self
def __exit__(self, *args: Any) -> None:
if self._client:
self._client.close()
self._client = None
@property
def client(self) -> httpx.Client:
if self._client is None:
raise RuntimeError("NaverApiClient는 with 문 안에서 사용하세요.")
return self._client
def _get_json(self, url: str) -> dict[str, Any]:
resp = self.client.get(url)
resp.raise_for_status()
return resp.json()
# ──────────────────────────────────────────
# 게임 정보
# ──────────────────────────────────────────
def fetch_game_info(self, game_id: str) -> dict[str, Any]:
"""게임 기본 정보"""
payload = self._get_json(f"{BASE_URL}/{game_id}")
return payload["result"]["game"]
def fetch_relay(self, game_id: str, inning: int | None = None) -> dict[str, Any]:
"""relay 데이터 (전체 또는 특정 이닝)"""
url = f"{BASE_URL}/{game_id}/relay"
if inning is not None:
url += f"?inning={inning}"
payload = self._get_json(url)
return payload["result"]["textRelayData"]
def fetch_record(self, game_id: str) -> dict[str, Any]:
"""기록 데이터 (투수/타자 기록)"""
payload = self._get_json(f"{BASE_URL}/{game_id}/record?fields=all")
return payload["result"]["recordData"]
def fetch_preview(self, game_id: str) -> dict[str, Any]:
"""프리뷰 데이터 (예비 라인업 포함)"""
payload = self._get_json(f"{BASE_URL}/{game_id}/preview")
return payload["result"].get("previewData") or {}
# ──────────────────────────────────────────
# KBO 공식 사이트 데이터
# ──────────────────────────────────────────
def fetch_kbo_review_meta(
self, game_id: str, game_info: dict[str, Any],
) -> dict[str, Any]:
"""KBO 공식 사이트에서 종료시간/관중수 등 메타 정보 조회"""
game_type = infer_game_type(game_info)
candidates = kbo_sr_id_candidates().get(game_type, kbo_sr_id_candidates()["정규경기"])
kbo_game_id = to_kbo_game_id(game_id)
for sr_id in candidates:
resp = self.client.post(
KBO_URL,
data={
"leId": "1",
"srId": sr_id,
"seasonId": str(game_info.get("seasonYear") or ""),
"gameId": kbo_game_id,
},
)
resp.raise_for_status()
payload = resp.json()
if str(payload.get("code")) != "100":
continue
if not any(payload.get(key) for key in ("END_TM", "START_TM", "USE_TM", "CROWD_CN")):
continue
return payload
return {}
# ──────────────────────────────────────────────
# 유틸리티 함수 (순수)
# ──────────────────────────────────────────────
def clean_game_id(game_id: str) -> str:
"""game_id에서 알파벳+숫자만 추출"""
return "".join(re.findall(r"[A-Za-z0-9]", game_id))
def get_team_names(
game_id: str, game_info: dict[str, Any] | None = None,
) -> tuple[str, str]:
"""game_id 또는 game_info에서 원정/홈 팀명 추출"""
if game_info:
return game_info["awayTeamName"], game_info["homeTeamName"]
code_map = team_code_map()
away_code = game_id[8:10]
home_code = game_id[10:12]
return code_map.get(away_code, away_code), code_map.get(home_code, home_code)
def infer_game_type(game_info: dict[str, Any]) -> str:
"""게임 정보에서 경기유형 추론"""
round_code = str(game_info.get("roundCode") or "").lower()
round_name = str(game_info.get("roundName") or "").strip()
if round_name:
return round_name
gt_map = game_type_map()
for key, label in gt_map.items():
if key in round_code:
return label
return "정규경기"
def to_kbo_game_id(game_id: str) -> str:
"""네이버 game_id → KBO 공식 game_id"""
return f"{game_id[:12]}0"
def build_iso_datetime(game_date: str | None, hhmm: str | None) -> str | None:
"""날짜 + 시:분 → ISO datetime 문자열"""
if not game_date or not hhmm:
return None
time_text = hhmm.strip()
if not time_text or ":" not in time_text:
return None
hour_text, minute_text = time_text.split(":", 1)
try:
dt = datetime.fromisoformat(f"{game_date}T{int(hour_text):02d}:{int(minute_text):02d}:00")
except ValueError:
return None
return dt.isoformat()
def derive_umpires(record_data: dict[str, Any]) -> dict[str, str | None]:
"""기록 데이터에서 심판 정보 추출"""
umpire_record = next(
(item for item in record_data.get("etcRecords", []) if item.get("how") == "심판"),
None,
)
names = umpire_record.get("result", "").split() if umpire_record else []
return {
"chief": names[0] if len(names) > 0 else None,
"first_base": names[1] if len(names) > 1 else None,
"second_base": names[2] if len(names) > 2 else None,
"third_base": names[3] if len(names) > 3 else None,
}
def extract_pitching_summary(record_data: dict[str, Any]) -> dict[str, list[str]]:
"""기록 데이터에서 투수 결과 요약 추출"""
label_map = result_labels()
summary: dict[str, list[str]] = {"승리투수": [], "패전투수": [], "홀드": [], "세이브": []}
for pitcher in record_data.get("pitchingResult", []):
label = label_map.get(pitcher.get("wls"))
if label and label in summary:
summary[label].append(pitcher["name"])
return summary

535
crawler/relay_parser.py Normal file
View File

@@ -0,0 +1,535 @@
"""
crawler/relay_parser.py — relay 데이터 파싱
네이버 textRelays를 분석하여 이닝별/타석별 구조화된 이벤트로 변환합니다.
"""
from __future__ import annotations
import re
from collections import defaultdict
from typing import Any
from core.config_loader import (
skip_option_types,
hidden_event_texts,
change_keywords,
max_inning,
)
from core.review_parser import parse_review_event_text
# ──────────────────────────────────────────────
# 정렬 키
# ──────────────────────────────────────────────
def _option_seqno(option: dict[str, Any]) -> int:
return int(option.get("seqno", -1))
def _relay_seqno(relay: dict[str, Any]) -> int:
seqnos = [
_option_seqno(opt)
for opt in relay.get("textOptions", [])
if opt.get("seqno") is not None
]
return min(seqnos) if seqnos else -1
# ──────────────────────────────────────────────
# 제목 추출
# ──────────────────────────────────────────────
def get_half_inning_title(
relays: list[dict[str, Any]], inning: int, home_or_away: int,
) -> str:
"""이닝 시작 릴레이에서 제목 추출"""
for relay in relays:
for opt in relay.get("textOptions", []):
if opt.get("type") == 0:
return opt.get("text", "").strip()
half_label = "" if home_or_away == 0 else ""
return f"{inning}{half_label}"
def _get_batter_title(relay: dict[str, Any], options: list[dict[str, Any]]) -> str:
"""릴레이 블록에서 타자 이름/제목 추출"""
batter_title = next(
(opt.get("text", "").strip() for opt in options if opt.get("type") == 8),
"",
)
if batter_title:
return batter_title
title = (relay.get("title") or "").strip()
if title and "공격" not in title and not title.startswith("="):
return title
return ""
# ──────────────────────────────────────────────
# 투구/주루/교체 파싱
# ──────────────────────────────────────────────
def _format_pitch_text(option: dict[str, Any]) -> str:
"""투구 옵션 → 포맷된 텍스트"""
text = option.get("text", "").strip()
speed = str(option.get("speed") or "").strip()
stuff = str(option.get("stuff") or "").strip()
details = []
if speed:
details.append(f"{speed}km")
if stuff:
details.append(stuff)
return f"{text} ({', '.join(details)})" if details else text
def _classify_pitch_result(text: str, code: str | None) -> str:
"""투구 결과 텍스트 + 코드 → 정규화된 결과 코드"""
normalized = text.replace(" ", "")
if any(key in normalized for key in ("번트헛스윙", "헛스윙번트", "번트시도스트라이크")):
return "BS"
if any(key in normalized for key in ("번트파울", "번트파울.")):
return "BF"
if code in {"BS", "BF", "B", "T", "S", "F", "H"}:
return code
if code and code != "V":
return code
mapping = {
"번트 헛스윙": "BS",
"번트헛스윙": "BS",
"번트 파울": "BF",
"번트파울": "BF",
"": "B",
"스트라이크": "T",
"헛스윙": "S",
"파울": "F",
"타격": "H",
}
for key, value in mapping.items():
if key in text:
return value
return ""
def _classify_result_type(text: str) -> str:
"""결과 텍스트 → result.type 코드"""
clean_text = text.replace(" ", "")
if "낫아웃" in clean_text:
return "strikeout_not_out"
if "고의사구" in text:
return "intentional_walk"
if "볼넷" in text:
return "walk"
if "삼진" in text:
return "strikeout"
if any(k in text for k in ["몸에 맞는 볼", "몸에 맞는 공", "사구", "헤드샷"]):
return "hit_by_pitch"
if "홈런" in text:
return "home_run"
if "3루타" in text:
return "triple"
if "2루타" in text:
return "double"
if "번트안타" in text:
return "bunt_hit"
if "1루타" in text or "내야안타" in text:
return "single"
if "실책" in text and "출루" in text:
return "reach_on_error"
if "야수선택" in text:
return "reach_on_fielder_choice"
if "땅볼로 출루" in text or "땅볼출루" in text:
return "reach_on_grounder"
if "희생번트" in text:
return "sacrifice_bunt"
if "희생플라이" in text:
return "sacrifice_fly"
if "병살타" in text:
return "double_play"
if any(k in text for k in [
"플라이 아웃", "땅볼 아웃", "인필드플라이 아웃",
"라인드라이브 아웃", "직선타 아웃", "라인드라이브", "직선타",
]):
return "out"
return "play"
def _parse_runner_event(text: str) -> dict[str, Any]:
"""주루 이벤트 텍스트 → 구조화된 dict"""
event_type = "runner_event"
if "도루" in text:
event_type = "steal_fail" if "실패" in text else "steal"
elif "홈인" in text:
event_type = "score"
elif "포스아웃" in text:
event_type = "force_out"
elif "견제사" in text:
event_type = "pickoff_out"
elif "태그아웃" in text:
event_type = "tag_out"
elif "실책" in text:
event_type = "error_advance"
elif "폭투" in text:
event_type = "wild_pitch_advance"
elif "포일" in text:
event_type = "passed_ball_advance"
elif "진루" in text:
event_type = "advance"
from_base = None
to_base = None
for label, base in (("1루주자", 1), ("2루주자", 2), ("3루주자", 3), ("1루", 1), ("2루", 2), ("3루", 3)):
if label in text and from_base is None:
from_base = base
for label, base in (("1루까지", 1), ("2루까지", 2), ("3루까지", 3)):
if label in text:
to_base = base
if "홈인" in text:
to_base = 4
runner_name = (
text.split(" : ", 1)[0]
.replace("1루주자 ", "")
.replace("2루주자 ", "")
.replace("3루주자 ", "")
.replace("대주자 ", "")
.strip()
)
extra_advance = 0
if "주자의 재치로" in text and from_base is not None and to_base is not None:
extra_advance = max(0, to_base - from_base)
# action_label: 관리자 사이트 버튼 라벨 매핑
clean_text = text.replace(" ", "")
if "실책으로" in clean_text:
action_label = "수비 실책"
elif "도루" in clean_text:
action_label = "도루성공" if "실패" not in clean_text else "도루시도 아웃"
elif "폭투" in clean_text:
action_label = "폭투-진루성공"
elif "포일" in clean_text:
action_label = "포일-진루성공"
elif "태그" in clean_text:
action_label = "태그아웃"
elif "포스" in clean_text:
action_label = "포스아웃"
elif "견제" in clean_text:
action_label = "견제 아웃"
elif any(k in clean_text for k in ["볼넷", "포볼", "고의사구", "몸에맞는", "사구"]):
action_label = "볼넷 진루"
else:
action_label = "일반 진루"
return {
"type": event_type,
"runner": runner_name,
"fromBase": from_base,
"toBase": to_base,
"extra_advance": extra_advance,
"text": text,
"action_label": action_label,
}
def _parse_change_event(text: str) -> dict[str, Any]:
"""교체 텍스트 → 구조화된 dict"""
event: dict[str, Any] = {
"event_type": "change",
"change_type": "position_change" if "수비위치 변경" in text else "substitution",
"text": text,
}
actor_role, batter_order, actor_name = _extract_change_actor(text)
event["actor_role"] = actor_role
event["actor_name"] = actor_name
if batter_order:
event["bat_order"] = int(batter_order)
if "수비위치 변경" in text:
to_position = text.split(" : ", 1)[1].split("(으)로", 1)[0].strip()
event["player_name"] = actor_name
event["to_position"] = to_position
return event
rhs = text.split(" : ", 1)[1].split("(으)로 교체", 1)[0].strip()
in_role, _, in_name = _extract_change_actor(rhs)
event["out_player"] = actor_name
event["in_player"] = in_name
event["in_role"] = in_role
field_roles = {"투수", "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"}
if actor_role in field_roles and in_role == "투수":
event["change_type"] = "merged_pitcher_substitution"
event["player_name"] = actor_name
event["to_position"] = "지명타자"
event["pitcher_in_player"] = in_name
return event
extra_roles = field_roles | {"대타", "대주자"}
if in_role in extra_roles:
event["to_position"] = in_role if in_role not in {"대타", "대주자"} else None
return event
def _extract_change_actor(text: str) -> tuple[str | None, str | None, str]:
"""교체 텍스트에서 역할/타순/이름 추출"""
lhs = text.split(" : ", 1)[0].strip()
if "번타자 " in lhs:
order_match = re.search(r"(\d+)번타자\s+(.+)$", lhs)
if order_match:
return "batter", order_match.group(1), order_match.group(2).strip()
for role in (
"대타", "대주자", "1루주자", "2루주자", "3루주자", "주자",
"투수", "포수", "1루수", "2루수", "3루수",
"유격수", "좌익수", "중견수", "우익수",
):
if lhs.startswith(role + " "):
return role, None, lhs[len(role):].strip()
return None, None, lhs
# ──────────────────────────────────────────────
# 주루 이벤트 병합
# ──────────────────────────────────────────────
def _merge_runner_events(runner_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""동일 주자의 이벤트를 병합"""
merged: dict[str, dict[str, Any]] = {}
for r in runner_events:
name = r.get("runner")
if not name:
continue
if name in merged:
merged[name]["type"] = r.get("type", merged[name]["type"])
merged[name]["text"] += f" / {r.get('text', '')}"
if r.get("toBase"):
merged[name]["toBase"] = r["toBase"]
if r.get("extra_advance"):
merged[name]["extra_advance"] = r["extra_advance"]
if "태그아웃" in r.get("text", "") or r.get("type") == "tag_out":
merged[name]["type"] = "tag_out"
else:
merged[name] = dict(r)
return list(merged.values())
# ──────────────────────────────────────────────
# 릴레이 → 이벤트 리스트 변환
# ──────────────────────────────────────────────
def build_relay_events(relay: dict[str, Any]) -> list[dict[str, Any]]:
"""하나의 릴레이 블록 → 타석/교체 이벤트 리스트"""
skip_types = skip_option_types()
hidden_texts = hidden_event_texts()
chg_keywords = change_keywords()
options = sorted(relay.get("textOptions", []), key=_option_seqno)
# 1. 세그먼트 분리 (pitchNum 1이 새로 나오면 타자가 바뀐 것)
segments: list[list[dict[str, Any]]] = []
current_segment: list[dict[str, Any]] = []
for opt in options:
opt_type = opt.get("type")
if opt_type == 1 and opt.get("pitchNum") == 1:
if any(o.get("type") == 1 for o in current_segment):
segments.append(current_segment)
current_segment = []
current_segment.append(opt)
if current_segment:
segments.append(current_segment)
# 2. 각 세그먼트별 이벤트 생성
results: list[dict[str, Any]] = []
relay_batter_title = _get_batter_title(relay, options)
for i, seg_options in enumerate(segments):
seg_changes: list[dict[str, Any]] = []
seg_event_texts: list[str] = []
seg_pitches: list[dict[str, Any]] = []
seg_runner_events: list[dict[str, Any]] = []
seg_review_events: list[dict[str, Any]] = []
seg_extra_events: list[dict[str, Any]] = []
seg_result_text: str | None = None
seg_batter_name: str | None = next(
(o.get("text", "").strip() for o in seg_options if o.get("type") == 8),
None,
)
for opt in seg_options:
ot = opt.get("type")
txt = opt.get("text", "").strip()
if not txt or ot in skip_types:
continue
if txt in hidden_texts:
continue
if any(k in txt for k in chg_keywords):
seg_changes.append(_parse_change_event(txt))
continue
if ot == 1:
seg_event_texts.append(_format_pitch_text(opt))
seg_pitches.append({
"pitchNo": opt.get("pitchNum"),
"pitchResult": _classify_pitch_result(txt, opt.get("pitchResult")),
"pitchResultText": txt.replace(f"{opt.get('pitchNum')}", "", 1),
"speedKmh": int(opt["speed"]) if opt.get("speed") not in (None, "") else None,
"pitchType": opt.get("stuff"),
"runnerEvents": [],
})
continue
if ot == 14:
if seg_pitches:
seg_pitches[-1]["runnerEvents"].append(_parse_runner_event(txt))
else:
seg_runner_events.append(_parse_runner_event(txt))
continue
if ot == 24:
seg_runner_events.append(_parse_runner_event(txt))
continue
seg_event_texts.append(txt)
if "비디오 판독" in txt or "합의 판정" in txt:
seg_review_events.append(parse_review_event_text(txt))
elif "체크스윙" in txt:
seg_extra_events.append({"type": "appeal_or_judgement", "text": txt})
elif any(r in txt for r in ["1루주자", "2루주자", "3루주자", "대주자", "도루", "홈인", "포스아웃"]) or ("진루" in txt and "출루" not in txt):
seg_runner_events.append(_parse_runner_event(txt))
else:
seg_result_text = txt
if " : " in txt and seg_batter_name is None:
name_part = txt.split(" : ", 1)[0].strip()
if name_part and len(name_part) < 10:
seg_batter_name = name_part
if not seg_batter_name:
seg_batter_name = relay_batter_title if i == 0 else ""
# 주루 이벤트 병합
for p in seg_pitches:
p["runnerEvents"] = _merge_runner_events(p["runnerEvents"])
seg_merged_runners = _merge_runner_events(seg_runner_events)
# 타자 결과 객체
res_obj = None
if seg_result_text:
base_type = _classify_result_type(seg_result_text)
res_obj = {"type": base_type, "text": seg_result_text}
b_name = seg_batter_name.split()[-1] if seg_batter_name else ""
final_runners = []
for r in seg_merged_runners:
if b_name and r.get("runner") == b_name:
if base_type in {"single", "double", "triple"}:
r_type = r.get("type", "")
if r_type in {"tag_out", "force_out", "steal_fail", "pickoff_out"}:
res_obj["type"] = f"{base_type}_runner_out"
elif r_type == "error_advance":
res_obj["type"] = f"{base_type}_error_advance"
if r.get("toBase"):
res_obj["toBase"] = r["toBase"]
if r.get("extra_advance"):
res_obj["extra_advance"] = r["extra_advance"]
else:
final_runners.append(r)
seg_merged_runners = final_runners
if seg_changes:
results.extend(seg_changes)
if seg_event_texts:
full_txt = (
f"{seg_batter_name} : " + ", ".join(seg_event_texts)
if seg_batter_name
else ", ".join(seg_event_texts)
)
results.append({
"event_type": "at_bat",
"batter": seg_batter_name,
"rawText": full_txt,
"pitches": seg_pitches,
"result": res_obj,
"runnerEvents": seg_merged_runners,
"reviewEvents": seg_review_events,
"extraEvents": seg_extra_events,
"changes": [],
})
return results
# ──────────────────────────────────────────────
# 이닝 빌드
# ──────────────────────────────────────────────
def build_half_inning(
inning: int, home_or_away: int, relays: list[dict[str, Any]],
) -> dict[str, Any]:
"""한 이닝의 한 쪽(초/말) 데이터를 구성"""
title = get_half_inning_title(relays, inning, home_or_away)
raw_events: list[dict[str, Any]] = []
for relay in sorted(relays, key=_relay_seqno):
raw_events.extend(build_relay_events(relay))
# 같은 타자의 연속 타석 병합
merged_events: list[dict[str, Any]] = []
for event in raw_events:
if not merged_events or event.get("event_type") != "at_bat":
merged_events.append(event)
continue
prev = merged_events[-1]
if prev.get("event_type") != "at_bat":
merged_events.append(event)
continue
current_pitches = event.get("pitches") or []
first_pitch_no = current_pitches[0].get("pitchNo", 0) if current_pitches else 0
is_same_batter = prev.get("batter") == event.get("batter")
if first_pitch_no > 1 or is_same_batter:
prev["pitches"].extend(current_pitches)
if event.get("result"):
prev["result"] = event["result"]
if event.get("rawText"):
current_txt = event["rawText"]
if " : " in current_txt:
current_txt = current_txt.split(" : ", 1)[1]
prev["rawText"] += " / " + current_txt
prev["runnerEvents"].extend(event.get("runnerEvents") or [])
prev["reviewEvents"].extend(event.get("reviewEvents") or [])
prev["extraEvents"].extend(event.get("extraEvents") or [])
continue
merged_events.append(event)
return {
"inning": inning,
"half": "top" if home_or_away == 0 else "bottom",
"title": title,
"events": merged_events,
}
def parse_inning_value(val: Any, default: float) -> float:
"""이닝 인수 파싱 ('1T' → 1.0, '3B' → 3.5)"""
if val is None:
return default
s = str(val).upper().strip()
if not s:
return default
m = re.match(r"^(\d+)([TB]?)$", s)
if not m:
try:
return float(s)
except ValueError:
return default
num = int(m.group(1))
suffix = m.group(2)
if suffix == "T":
return float(num)
if suffix == "B":
return num + 0.5
return float(num)

270
crawler/report_builder.py Normal file
View File

@@ -0,0 +1,270 @@
"""
crawler/report_builder.py — 최종 JSON 리포트 생성
네이버 API 데이터를 수집하고, relay 파싱 결과를 합쳐서
정규화된 게임 리포트 JSON을 생성/저장합니다.
"""
from __future__ import annotations
import json
from collections import defaultdict
from pathlib import Path
from typing import Any
from core.config_loader import max_inning
from crawler.naver_api import (
NaverApiClient,
build_iso_datetime,
clean_game_id,
derive_umpires,
extract_pitching_summary,
get_team_names,
infer_game_type,
)
from crawler.relay_parser import build_half_inning, parse_inning_value
from crawler.lineup_builder import build_lineup_summary
# ──────────────────────────────────────────────
# 이닝 데이터 수집
# ──────────────────────────────────────────────
def collect_inning_data(
api: NaverApiClient,
game_id: str,
start_inning_val: str | None = None,
end_inning_val: str | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""모든 이닝 relay 데이터를 수집하여 구조화"""
innings: list[dict[str, Any]] = []
raw_relays: list[dict[str, Any]] = []
start_score = parse_inning_value(start_inning_val, 0.0)
end_score = parse_inning_value(end_inning_val, 99.0)
for inning in range(1, max_inning() + 1):
try:
relay_data = api.fetch_relay(game_id, inning=inning)
except Exception:
break
relays = relay_data.get("textRelays", [])
if not relays:
break
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
for relay in relays:
grouped[int(relay.get("homeOrAway", -1))].append(relay)
raw_relays.append(relay)
for home_or_away in (0, 1):
half_relays = grouped.get(home_or_away, [])
if not half_relays:
continue
current_score = inning + (0.5 if home_or_away == 1 else 0.0)
if current_score < start_score or current_score > end_score:
continue
innings.append(build_half_inning(inning, home_or_away, half_relays))
return innings, raw_relays
# ──────────────────────────────────────────────
# 점수 타임라인 & 블론세이브
# ──────────────────────────────────────────────
def _collect_score_timeline(raw_relays: list[dict[str, Any]]) -> list[dict[str, Any]]:
timeline: list[dict[str, Any]] = []
for relay in raw_relays:
for option in relay.get("textOptions", []):
state = option.get("currentGameState") or {}
if not state:
continue
timeline.append({
"seqno": option.get("seqno"),
"home_score": int(state.get("homeScore", 0)),
"away_score": int(state.get("awayScore", 0)),
})
timeline.sort(key=lambda item: item["seqno"])
return timeline
def _collect_blown_saves(
raw_relays: list[dict[str, Any]], away_name: str, home_name: str,
) -> list[str]:
timeline = _collect_score_timeline(raw_relays)
blown_save_pitchers: list[str] = []
pitcher_entries: list[dict[str, Any]] = []
for relay in raw_relays:
inning = int(relay.get("inn", 0) or 0)
if inning < 7:
continue
batting_side = int(relay.get("homeOrAway", -1))
pitcher_team = "home" if batting_side == 0 else "away"
pitcher_team_name = home_name if pitcher_team == "home" else away_name
for option in relay.get("textOptions", []):
if option.get("type") != 2:
continue
player_change = option.get("playerChange") or {}
in_player = player_change.get("inPlayer") or {}
if in_player.get("playerPos") != "투수":
continue
state = option.get("currentGameState") or {}
pitcher_entries.append({
"name": in_player.get("playerName"),
"team": pitcher_team,
"team_name": pitcher_team_name,
"entry_seqno": option.get("seqno"),
"home_score": int(state.get("homeScore", 0)),
"away_score": int(state.get("awayScore", 0)),
})
for entry in pitcher_entries:
team_score = entry["home_score"] if entry["team"] == "home" else entry["away_score"]
opp_score = entry["away_score"] if entry["team"] == "home" else entry["home_score"]
if team_score <= opp_score:
continue
for state in timeline:
if state["seqno"] <= entry["entry_seqno"]:
continue
current_team = state["home_score"] if entry["team"] == "home" else state["away_score"]
current_opp = state["away_score"] if entry["team"] == "home" else state["home_score"]
if current_team <= current_opp:
blown_save_pitchers.append(entry["name"])
break
return sorted(set(blown_save_pitchers))
# ──────────────────────────────────────────────
# 게임 정보 빌드
# ──────────────────────────────────────────────
def _build_game_info(
game_info: dict[str, Any],
record_data: dict[str, Any],
review_meta: dict[str, Any],
) -> dict[str, Any]:
end_time = build_iso_datetime(game_info.get("gameDate"), review_meta.get("END_TM"))
return {
"date": game_info.get("gameDate"),
"stadium": game_info.get("stadium"),
"start_time": game_info.get("gameDateTime"),
"end_time": end_time,
"season": game_info.get("seasonYear"),
"game_type": infer_game_type(game_info),
"home_team": game_info.get("homeTeamName"),
"away_team": game_info.get("awayTeamName"),
"attendance": review_meta.get("CROWD_CN"),
"umpires": derive_umpires(record_data),
}
def _build_pitcher_section(
record_data: dict[str, Any],
raw_relays: list[dict[str, Any]],
away_name: str,
home_name: str,
) -> dict[str, list[str]]:
summary = extract_pitching_summary(record_data)
summary["블론세이브"] = _collect_blown_saves(raw_relays, away_name, home_name)
return summary
# ──────────────────────────────────────────────
# 리포트 빌드 & 저장
# ──────────────────────────────────────────────
def build_report(
game_id: str,
start_inning: str | None = None,
end_inning: str | None = None,
) -> dict[str, Any]:
"""게임 ID로 전체 리포트 생성
네이버 API 4종 + KBO 메타를 수집하여 정규화된 JSON dict 반환.
"""
game_id = clean_game_id(game_id)
with NaverApiClient() as api:
relay_data = api.fetch_relay(game_id)
record_data = api.fetch_record(game_id)
game_info = api.fetch_game_info(game_id)
preview_data = api.fetch_preview(game_id)
review_meta = api.fetch_kbo_review_meta(game_id, game_info)
lineup_summary = build_lineup_summary(game_id, game_info, relay_data, preview_data)
innings, raw_relays = collect_inning_data(
api, game_id,
start_inning_val=start_inning,
end_inning_val=end_inning,
)
pitcher_section = _build_pitcher_section(
record_data, raw_relays,
lineup_summary["away_team"]["team_name"],
lineup_summary["home_team"]["team_name"],
)
return {
"game_id": game_id,
"game_info": _build_game_info(game_info, record_data, review_meta),
"lineups": lineup_summary,
"game_contents": innings,
"pitching_summary": pitcher_section,
}
def filter_report(
report: dict[str, Any],
inning: str | None = None,
lineup_only: bool = False,
start_inning: str | None = None,
end_inning: str | None = None,
) -> dict[str, Any]:
"""리포트에서 특정 이닝만 필터링"""
filtered = json.loads(json.dumps(report, ensure_ascii=False))
if lineup_only:
filtered["game_contents"] = []
filtered["pitching_summary"] = {
"승리투수": [], "패전투수": [], "홀드": [], "세이브": [], "블론세이브": [],
}
return filtered
start_v = parse_inning_value(start_inning, 0.0)
end_v = parse_inning_value(end_inning, 99.0)
if inning is not None:
iv = parse_inning_value(inning, 0.0)
start_v = iv
end_v = iv + 0.5
filtered["game_contents"] = [
half
for half in filtered.get("game_contents", [])
if start_v <= (
float(half.get("inning") or 0)
+ (0.5 if half.get("half") == "bottom" else 0.0)
) <= end_v
]
return filtered
def save_report(
report: dict[str, Any],
output_dir: Path,
output_json: Path | None = None,
) -> Path:
"""리포트를 JSON 파일로 저장"""
output_dir.mkdir(parents=True, exist_ok=True)
game_id = report["game_id"]
json_path = output_json or (output_dir / f"{game_id}_report.json")
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return json_path

View File

@@ -0,0 +1 @@
{"disable-features":"AutoDeElevate,AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,OptimizationHints,PaintHolding,RenderDocument,ThirdPartyStoragePartitioning,Translate","enable-features":"CDPScreenshotNewSurface,UkmSamplingRate\u003CUkmSamplingRate","force-fieldtrial-params":"UkmSamplingRate.Sampled_NoSeed_Stable:_default_sampling/1000000","force-fieldtrials":"*SeedFileTrial/Default/UkmSamplingRate/Sampled_NoSeed_Stable"}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
{
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
$F~

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
my_profile/Default/Cookies Normal file

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
MANIFEST-000001

View File

View File

@@ -0,0 +1,2 @@
2026/05/02-16:17:44.440 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Rules since it was missing.
2026/05/02-16:17:44.442 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Rules/MANIFEST-000001

Binary file not shown.

View File

@@ -0,0 +1 @@
MANIFEST-000001

View File

@@ -0,0 +1,2 @@
2026/05/02-16:17:44.442 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Scripts since it was missing.
2026/05/02-16:17:44.444 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Scripts/MANIFEST-000001

Binary file not shown.

View File

@@ -0,0 +1 @@
MANIFEST-000001

View File

View File

@@ -0,0 +1,2 @@
2026/05/02-16:17:44.775 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension State since it was missing.
2026/05/02-16:17:44.777 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension State/MANIFEST-000001

Binary file not shown.

BIN
my_profile/Default/Favicons Normal file

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
my_profile/Default/History Normal file

Binary file not shown.

View File

0
my_profile/Default/LOCK Normal file
View File

0
my_profile/Default/LOG Normal file
View File

View File

@@ -0,0 +1 @@
MANIFEST-000001

Some files were not shown because too many files have changed in this diff Show More