Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906718067b |
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
automation/ — Playwright 기반 관리자 사이트 자동 입력 패키지
|
||||
|
||||
config/와 core/에 의존하며, 관리자 사이트의 UI 조작을 담당합니다.
|
||||
"""
|
||||
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,299 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,338 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
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))
|
||||
@@ -1,233 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,271 +0,0 @@
|
||||
"""
|
||||
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
161
cli.py
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
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())
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
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)")
|
||||
@@ -1,212 +0,0 @@
|
||||
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
|
||||
@@ -1,556 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,29 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -1,39 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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]
|
||||
@@ -1,83 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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]
|
||||
@@ -1,76 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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]
|
||||
@@ -1,32 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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: [인정, 불인정]
|
||||
@@ -1,29 +0,0 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# 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"
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
core/ — 순수 비즈니스 로직 패키지
|
||||
|
||||
Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다.
|
||||
모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다.
|
||||
"""
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,195 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,255 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
@@ -1,273 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
crawler/ — 네이버 스포츠 API 크롤링 패키지
|
||||
|
||||
네이버 API에서 데이터를 수집하고, relay 데이터를 파싱하여
|
||||
정규화된 JSON 리포트를 생성합니다.
|
||||
"""
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
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"]),
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,535 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,270 +0,0 @@
|
||||
"""
|
||||
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
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"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.
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
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.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
$F~
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
MANIFEST-000001
|
||||
@@ -1,2 +0,0 @@
|
||||
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.
@@ -1 +0,0 @@
|
||||
MANIFEST-000001
|
||||
@@ -1,2 +0,0 @@
|
||||
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.
@@ -1 +0,0 @@
|
||||
MANIFEST-000001
|
||||
@@ -1,2 +0,0 @@
|
||||
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.
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.
@@ -1 +0,0 @@
|
||||
MANIFEST-000001
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user