refactoring
This commit is contained in:
5
automation/__init__.py
Normal file
5
automation/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
automation/ — Playwright 기반 관리자 사이트 자동 입력 패키지
|
||||
|
||||
config/와 core/에 의존하며, 관리자 사이트의 UI 조작을 담당합니다.
|
||||
"""
|
||||
322
automation/batter_input.py
Normal file
322
automation/batter_input.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
automation/batter_input.py — 타석 결과 입력
|
||||
|
||||
타석의 결과(타구 좌표, 타격 결과, 타자 진루)를 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.pitch_classifier import infer_batter_result_label, is_ball_in_play_event
|
||||
from core.field_calculator import (
|
||||
infer_hit_ball_type,
|
||||
infer_field_zone,
|
||||
get_hit_ball_type_code,
|
||||
get_zone_coordinates,
|
||||
get_foul_fly_coordinates,
|
||||
extract_direction_offsets,
|
||||
is_infield_zone,
|
||||
)
|
||||
from automation.page_helpers import (
|
||||
click_radio_by_label,
|
||||
get_checked_event_name,
|
||||
get_last_visible_enabled_locator,
|
||||
)
|
||||
from automation.defense_popup import (
|
||||
fill_error_defense_popup,
|
||||
click_defense_sequence_in_popup,
|
||||
fill_runner_out_defense,
|
||||
)
|
||||
|
||||
|
||||
def _deterministic_offset(seed_text: str, radius: int) -> tuple[int, int]:
|
||||
"""텍스트 기반 결정적 난수 오프셋 생성"""
|
||||
digest = hashlib.md5(seed_text.encode("utf-8")).digest()
|
||||
x_offset = (digest[0] % (radius * 2 + 1)) - radius
|
||||
y_offset = (digest[1] % (radius * 2 + 1)) - radius
|
||||
return x_offset, y_offset
|
||||
|
||||
|
||||
def _apply_hit_ball_variation(
|
||||
result_text: str, result_type: str, zone: str, x: int, y: int,
|
||||
) -> tuple[int, int]:
|
||||
"""타구 텍스트에 따른 좌표 변화율(오프셋) 적용"""
|
||||
dir_x, dir_y = extract_direction_offsets(result_text)
|
||||
|
||||
if "파울플라이" in result_text or "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
|
||||
is_left = any(token in result_text for token in ("좌", "3루", "유격"))
|
||||
foul_x, foul_y = get_foul_fly_coordinates("left" if is_left else "right")
|
||||
x_offset, y_offset = _deterministic_offset(result_text, 2)
|
||||
return (
|
||||
max(0, min(100, foul_x + x_offset)),
|
||||
max(50, min(100, foul_y + y_offset)),
|
||||
)
|
||||
|
||||
if result_type == "home_run":
|
||||
x_offset, y_offset = _deterministic_offset(result_text, 2)
|
||||
return (
|
||||
max(15, min(85, x + x_offset)),
|
||||
max(12, min(22, y + y_offset)),
|
||||
)
|
||||
|
||||
if is_infield_zone(zone):
|
||||
base_shift = 3
|
||||
random_radius = 2 if result_type == "out" else 3
|
||||
else:
|
||||
base_shift = 12
|
||||
random_radius = 5 if result_type == "out" else 7
|
||||
|
||||
x_offset, y_offset = _deterministic_offset(result_text, random_radius)
|
||||
return (
|
||||
max(10, min(90, x + dir_x * base_shift + x_offset)),
|
||||
max(18, min(96, y + dir_y * base_shift + y_offset)),
|
||||
)
|
||||
|
||||
|
||||
def build_hit_ball_payload(page: Page, result_text: str) -> dict[str, str]:
|
||||
"""타구 좌표 및 거리 페이로드 생성"""
|
||||
zone = infer_field_zone(result_text)
|
||||
x, y = get_zone_coordinates(zone)
|
||||
meter_per_px_text = page.locator("#dat_meterPerPx").input_value() or "0"
|
||||
try:
|
||||
meter_per_px = float(meter_per_px_text)
|
||||
except ValueError:
|
||||
meter_per_px = 0.0
|
||||
|
||||
result_type = "home_run" if "홈런" in result_text else ("out" if "아웃" in result_text or "희생" in result_text else "safe")
|
||||
hit_ball_type_label = infer_hit_ball_type(result_text)
|
||||
x, y = _apply_hit_ball_variation(result_text, result_type, zone, x, y)
|
||||
|
||||
px_x = math.floor(650 * x / 100)
|
||||
px_y = math.floor(621 * y / 100)
|
||||
|
||||
distance = 0
|
||||
if meter_per_px:
|
||||
distance = math.floor(math.sqrt((50 - x) ** 2 + (95 - y) ** 2) * math.hypot(6.5, 6.21) * meter_per_px / 100)
|
||||
|
||||
return {
|
||||
"type": get_hit_ball_type_code(hit_ball_type_label),
|
||||
"label": hit_ball_type_label,
|
||||
"x": str(px_x),
|
||||
"y": str(px_y),
|
||||
"xy": f"{x},{y}",
|
||||
"distance": str(distance),
|
||||
}
|
||||
|
||||
|
||||
def set_hit_ball_and_defense(page: Page, event: dict[str, Any]) -> bool:
|
||||
"""구장 팝업이 열렸을 때 타구 좌표 지정 및 수비 결과 입력"""
|
||||
if not is_ball_in_play_event(event):
|
||||
return False
|
||||
|
||||
result_text = ((event.get("result") or {}).get("text") or "").strip()
|
||||
if not result_text:
|
||||
return False
|
||||
|
||||
# 팝업 가시성 대기
|
||||
try:
|
||||
page.wait_for_selector("#div_stadium_image", state="visible", timeout=2000)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 타구 좌표 계산 및 입력
|
||||
payload = build_hit_ball_payload(page, result_text)
|
||||
page.evaluate(
|
||||
"""(payload) => {
|
||||
const mapImg = document.getElementById('mapImg');
|
||||
if (!mapImg) return;
|
||||
|
||||
document.getElementById('dat_evt_hit_type').value = payload.type;
|
||||
const dropDown = document.querySelector("#div_hit_type button.dropdown-toggle");
|
||||
if (dropDown) {
|
||||
dropDown.innerHTML = payload.label + ' <span class="caret"></span>';
|
||||
}
|
||||
|
||||
document.getElementById('dat_hit_x').value = payload.x;
|
||||
document.getElementById('dat_hit_y').value = payload.y;
|
||||
document.getElementById('dat_hit_xy').value = payload.xy;
|
||||
document.getElementById('dat_hit_distance').value = payload.distance;
|
||||
document.getElementById('distance').value = payload.distance;
|
||||
|
||||
const mark = document.getElementById('map_mark');
|
||||
if (mark) {
|
||||
mark.style.display = 'block';
|
||||
mark.style.left = payload.x + 'px';
|
||||
mark.style.top = payload.y + 'px';
|
||||
}
|
||||
}""",
|
||||
payload,
|
||||
)
|
||||
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 타구 결과에 따른 수비 팝업/입력 처리
|
||||
result_type = (event.get("result") or {}).get("type") or ""
|
||||
|
||||
if result_type in {"single_error_advance", "double_error_advance", "triple_error_advance"}:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"reach_on_error"}:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"single_runner_out", "double_runner_out", "triple_runner_out"}:
|
||||
fill_runner_out_defense(page, result_text)
|
||||
elif "병살" in result_text:
|
||||
from core.field_calculator import build_double_play_first_sequence
|
||||
seq = build_double_play_first_sequence(event)
|
||||
if seq:
|
||||
click_defense_sequence_in_popup(page, seq)
|
||||
btn = get_last_visible_locator(page, "#btnNext")
|
||||
if btn:
|
||||
btn.click()
|
||||
page.wait_for_timeout(100)
|
||||
elif "실책" in result_text:
|
||||
fill_error_defense_popup(page, result_text)
|
||||
elif result_type in {"out", "double_play"} and "삼진" not in result_text:
|
||||
from core.field_calculator import extract_defense_sequence
|
||||
seq = extract_defense_sequence(result_text)
|
||||
if seq:
|
||||
click_defense_sequence_in_popup(page, seq, complete_button_selector="#btnAdd")
|
||||
|
||||
# 홈런일 경우 입력 완료 버튼 직접 클릭
|
||||
if result_type == "home_run":
|
||||
try:
|
||||
page.locator("#btnInputComplete").click(timeout=1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def set_batter_result_type(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
|
||||
"""타격 결과 종류(1루타, 수비실책 등)만 세팅"""
|
||||
if not result:
|
||||
return
|
||||
label = infer_batter_result_label(result, event)
|
||||
if not label:
|
||||
return
|
||||
|
||||
# 강제 세팅 (병살 등)
|
||||
if label == "병살-아웃":
|
||||
forced = page.evaluate(
|
||||
"""(eventName) => {
|
||||
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
|
||||
for (const node of nodes) {
|
||||
const name = (node.getAttribute('eventName') || '').trim();
|
||||
if (name === eventName) {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
label,
|
||||
)
|
||||
if forced:
|
||||
page.wait_for_timeout(120)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
return
|
||||
|
||||
# JS 강제 이벤트 발생
|
||||
marker = page.evaluate(
|
||||
"""(eventName) => {
|
||||
const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")];
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) {
|
||||
continue;
|
||||
}
|
||||
if ((node.getAttribute('eventName') || '') === eventName) {
|
||||
const marker = `codex-batter-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
label,
|
||||
)
|
||||
if marker:
|
||||
candidate = page.locator(f"[data-codex-marker='{marker}']")
|
||||
for _ in range(3):
|
||||
try:
|
||||
candidate.click(force=True)
|
||||
except Exception:
|
||||
candidate.evaluate(
|
||||
"""(node) => {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"""
|
||||
)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
return
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
# 폴백
|
||||
for _ in range(3):
|
||||
click_radio_by_label(page, "evt_batter", label)
|
||||
if get_checked_event_name(page, "evt_batter") == label:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
|
||||
def set_batter_advancement(page: Page, result: dict[str, Any] | None) -> None:
|
||||
"""타자의 최종 루(1루, 2루 등)와 주루가산 세팅"""
|
||||
if not result:
|
||||
return
|
||||
|
||||
to_base = result.get("toBase")
|
||||
|
||||
# 기본 진루
|
||||
if to_base is None:
|
||||
r_type = result.get("type")
|
||||
if r_type in {"single", "bunt_hit", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "hit_by_pitch", "walk", "intentional_walk"}:
|
||||
to_base = 1
|
||||
elif r_type == "double":
|
||||
to_base = 2
|
||||
elif r_type == "triple":
|
||||
to_base = 3
|
||||
elif r_type == "home_run":
|
||||
to_base = 4
|
||||
|
||||
if to_base is not None:
|
||||
try:
|
||||
selector = f"input[name='dat_evt_batter_advance'][value='{to_base}']"
|
||||
locator = get_last_visible_enabled_locator(page, selector)
|
||||
if locator is not None:
|
||||
locator.check(force=True)
|
||||
else:
|
||||
fallback = page.locator(selector)
|
||||
if fallback.count() > 0:
|
||||
fallback.first.check(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 주루가산 (Extra Advance)
|
||||
try:
|
||||
extra_advance = result.get("extra_advance")
|
||||
if extra_advance is not None and extra_advance > 0:
|
||||
locator = get_last_visible_enabled_locator(page, "#batterRunningAdd")
|
||||
if locator is not None:
|
||||
locator.select_option(value=str(extra_advance))
|
||||
else:
|
||||
fallback = page.locator("#batterRunningAdd")
|
||||
if fallback.count() > 0:
|
||||
fallback.first.select_option(value=str(extra_advance))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_batter_result(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None:
|
||||
"""타격 결과와 진루/가산 세팅"""
|
||||
set_batter_result_type(page, result, event)
|
||||
set_batter_advancement(page, result)
|
||||
118
automation/defense_popup.py
Normal file
118
automation/defense_popup.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
automation/defense_popup.py — 수비 팝업 조작
|
||||
|
||||
수비 버튼 클릭, 수비 시퀀스 입력, 실책 수비 팝업 처리.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.config_loader import defense_button_id_map, position_number_map
|
||||
from core.field_calculator import (
|
||||
extract_defense_sequence,
|
||||
extract_error_position,
|
||||
infer_error_position_fallback,
|
||||
is_throwing_error,
|
||||
)
|
||||
from automation.page_helpers import get_last_visible_locator
|
||||
|
||||
|
||||
def click_defense_button_robustly(page: Page, position: str, click_count: int = 1) -> bool:
|
||||
"""수비 포지션 버튼을 안정적으로 클릭
|
||||
|
||||
ID 기반 → value 기반 → label 기반 순서로 시도
|
||||
"""
|
||||
btn_map = defense_button_id_map()
|
||||
pos_num = position_number_map()
|
||||
|
||||
# 1) ID 기반
|
||||
button_selector = btn_map.get(position)
|
||||
if button_selector:
|
||||
loc = page.locator(button_selector)
|
||||
if loc.count() > 0:
|
||||
for _ in range(click_count):
|
||||
loc.click(force=True)
|
||||
page.wait_for_timeout(60)
|
||||
return True
|
||||
|
||||
# 2) value 기반
|
||||
value = pos_num.get(position)
|
||||
if value:
|
||||
loc = page.locator(f"input[name='defenseNumberBtn'][value='{value}']")
|
||||
if loc.count() > 0:
|
||||
for _ in range(click_count):
|
||||
loc.click(force=True)
|
||||
page.wait_for_timeout(60)
|
||||
return True
|
||||
|
||||
# 3) label 기반
|
||||
all_buttons = page.locator("input[name='defenseNumberBtn']").all()
|
||||
for btn in all_buttons:
|
||||
label = (btn.get_attribute("id") or "").lower()
|
||||
if position in label or label in position:
|
||||
for _ in range(click_count):
|
||||
btn.click(force=True)
|
||||
page.wait_for_timeout(60)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clear_defense_selections(page: Page) -> None:
|
||||
"""수비 선택 초기화"""
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelectorAll("input[name='defenseNumberBtn']").forEach(btn => {
|
||||
btn.checked = false;
|
||||
});
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def click_defense_sequence_in_popup(
|
||||
page: Page,
|
||||
sequence: list[str],
|
||||
complete_button_selector: str | None = None,
|
||||
) -> None:
|
||||
"""수비 시퀀스 순서대로 클릭 후 완료 버튼 클릭"""
|
||||
for position in sequence:
|
||||
click_defense_button_robustly(page, position)
|
||||
page.wait_for_timeout(80)
|
||||
|
||||
if complete_button_selector:
|
||||
btn = get_last_visible_locator(page, complete_button_selector)
|
||||
if btn:
|
||||
btn.click()
|
||||
page.wait_for_timeout(120)
|
||||
|
||||
|
||||
def fill_runner_out_defense(
|
||||
page: Page, text: str, sequence_override: list[str] | None = None,
|
||||
) -> None:
|
||||
"""주루 아웃 수비 팝업 처리"""
|
||||
page.wait_for_timeout(300)
|
||||
sequence = sequence_override or extract_defense_sequence(text)
|
||||
if sequence:
|
||||
click_defense_sequence_in_popup(page, sequence)
|
||||
|
||||
|
||||
def fill_error_defense_popup(page: Page, text: str) -> None:
|
||||
"""실책 수비 팝업 처리"""
|
||||
defense_sequence = extract_defense_sequence(text)
|
||||
if len(defense_sequence) >= 2:
|
||||
click_defense_sequence_in_popup(page, defense_sequence)
|
||||
else:
|
||||
error_position = extract_error_position(text)
|
||||
if not error_position:
|
||||
error_position = infer_error_position_fallback(text)
|
||||
click_count = 2 if is_throwing_error(text) else 1
|
||||
click_defense_button_robustly(page, error_position, click_count)
|
||||
|
||||
complete_button = get_last_visible_locator(page, "#btnNext")
|
||||
if complete_button is None:
|
||||
complete_button = get_last_visible_locator(page, "#btnAdd")
|
||||
if complete_button:
|
||||
complete_button.click()
|
||||
page.wait_for_timeout(120)
|
||||
141
automation/game_end_input.py
Normal file
141
automation/game_end_input.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
automation/game_end_input.py — 경기 종료 처리
|
||||
|
||||
투수들의 승패/홀드/세이브 기록 등을 경기 종료 팝업에 입력하고 저장합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from automation.page_helpers import show_debug_overlay
|
||||
from automation.lineup_input import normalize_lineup_text
|
||||
|
||||
|
||||
def _open_game_end_popup(page: Page) -> None:
|
||||
"""경기 종료 팝업 열기"""
|
||||
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
|
||||
page.locator("#gameEndBtn").click(force=True)
|
||||
page.wait_for_selector("#btnGameEnd", timeout=5000)
|
||||
page.wait_for_selector("input[name^='homeTeamPitcher_'], input[name^='awayTeamPitcher_']", timeout=5000)
|
||||
|
||||
|
||||
def _get_game_end_pitcher_rows(page: Page) -> dict[str, list[dict[str, Any]]]:
|
||||
"""팝업 내 홈/원정 투수 리스트 추출"""
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const rowsFor = (nameAttr) => {
|
||||
return [...document.querySelectorAll(`input[name='${nameAttr}']`)].map((input, idx) => {
|
||||
const tr = input.closest('tr');
|
||||
const firstTd = tr ? tr.querySelector('td') : null;
|
||||
return {
|
||||
idx,
|
||||
name: firstTd ? firstTd.textContent.trim() : '',
|
||||
};
|
||||
});
|
||||
};
|
||||
return {
|
||||
home: rowsFor('home_player_id'),
|
||||
away: rowsFor('away_player_id'),
|
||||
};
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def _select_game_end_role(page: Page, side: str, idx: int, role_value: str) -> None:
|
||||
"""특정 투수의 역할(승/패/홀/세 등) 라디오 버튼 선택"""
|
||||
selector = f"input[name='{side}TeamPitcher_{idx}'][value='{role_value}']"
|
||||
ok = page.evaluate(
|
||||
"""(selector) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return false;
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return node.checked === true;
|
||||
}""",
|
||||
selector,
|
||||
)
|
||||
if not ok:
|
||||
page.locator(selector).click(force=True)
|
||||
|
||||
|
||||
def _check_game_end_blown_save(page: Page, side: str, idx: int) -> None:
|
||||
"""블론세이브 체크박스 선택"""
|
||||
selector = f"input[name='{side}BlownSave_{idx}']"
|
||||
ok = page.evaluate(
|
||||
"""(selector) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return false;
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return node.checked === true;
|
||||
}""",
|
||||
selector,
|
||||
)
|
||||
if not ok:
|
||||
page.locator(selector).check(force=True)
|
||||
|
||||
|
||||
def fill_game_end_pitching(page: Page, report: dict[str, Any]) -> None:
|
||||
"""리포트의 투수 요약을 바탕으로 경기 종료 팝업 폼 채우기"""
|
||||
_open_game_end_popup(page)
|
||||
rows = _get_game_end_pitcher_rows(page)
|
||||
|
||||
summary = report.get("pitching_summary") or {}
|
||||
lineups = report.get("lineups") or {}
|
||||
|
||||
home_starter = normalize_lineup_text(((lineups.get("home_team") or {}).get("starter_pitcher") or {}).get("name") or "")
|
||||
away_starter = normalize_lineup_text(((lineups.get("away_team") or {}).get("starter_pitcher") or {}).get("name") or "")
|
||||
|
||||
winners = {normalize_lineup_text(name) for name in (summary.get("승리투수") or [])}
|
||||
losers = {normalize_lineup_text(name) for name in (summary.get("패전투수") or [])}
|
||||
holds = {normalize_lineup_text(name) for name in (summary.get("홀드") or [])}
|
||||
saves = {normalize_lineup_text(name) for name in (summary.get("세이브") or [])}
|
||||
blown_saves = {normalize_lineup_text(name) for name in (summary.get("블론세이브") or [])}
|
||||
fixed_roles = winners | losers | holds | saves
|
||||
|
||||
for side, side_rows in rows.items():
|
||||
starter_name = home_starter if side == "home" else away_starter
|
||||
for row in side_rows:
|
||||
name = normalize_lineup_text(row.get("name") or "")
|
||||
idx = int(row["idx"])
|
||||
|
||||
if name in winners:
|
||||
_select_game_end_role(page, side, idx, "wins")
|
||||
elif name in losers:
|
||||
_select_game_end_role(page, side, idx, "loses")
|
||||
elif name in saves:
|
||||
_select_game_end_role(page, side, idx, "save")
|
||||
elif name in holds:
|
||||
_select_game_end_role(page, side, idx, "holds")
|
||||
elif name and name != starter_name and name not in fixed_roles:
|
||||
_select_game_end_role(page, side, idx, "re")
|
||||
|
||||
if name in blown_saves:
|
||||
_check_game_end_blown_save(page, side, idx)
|
||||
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
show_debug_overlay(
|
||||
page,
|
||||
[
|
||||
"게임종료 팝업 입력 준비 완료",
|
||||
f"승리: {', '.join(summary.get('승리투수') or []) or '-'}",
|
||||
f"패전: {', '.join(summary.get('패전투수') or []) or '-'}",
|
||||
f"홀드: {', '.join(summary.get('홀드') or []) or '-'}",
|
||||
f"세이브: {', '.join(summary.get('세이브') or []) or '-'}",
|
||||
f"블론세이브: {', '.join(summary.get('블론세이브') or []) or '-'}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def submit_game_end(page: Page) -> None:
|
||||
"""경기 종료 최종 완료(저장) 버튼 클릭"""
|
||||
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
|
||||
page.locator("#btnGameEnd").click(force=True)
|
||||
page.wait_for_timeout(1500)
|
||||
299
automation/lineup_input.py
Normal file
299
automation/lineup_input.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
automation/lineup_input.py — 라인업 및 선수 교체 입력
|
||||
|
||||
관리자 사이트의 홈/원정 라인업 조작, 선수명 및 포지션 선택,
|
||||
교체 이벤트 처리를 담당합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.config_loader import position_to_defense_no
|
||||
from core.change_parser import normalize_change_event
|
||||
|
||||
|
||||
def normalize_lineup_text(text: str) -> str:
|
||||
"""라인업 선수명 정규화 (괄호, 번호 등 제거하고 이름만 추출)"""
|
||||
text = (text or "").strip()
|
||||
text = text.replace("*", "")
|
||||
text = re.sub(r"\[\d+(?:번)?\]", "", text)
|
||||
text = re.sub(r"\s*\(.*?\)\s*", "", text)
|
||||
text = "".join(re.findall(r"[가-힣A-Za-z]+", text))
|
||||
return text.strip()
|
||||
|
||||
|
||||
def get_lineup_state(page: Page) -> dict[str, Any]:
|
||||
"""현재 사이트에 입력된 라인업 상태(홈/원정 0~9번 행) 추출"""
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const buildSide = (side) => {
|
||||
const rows = [];
|
||||
for (let idx = 0; idx <= 9; idx += 1) {
|
||||
const player = document.querySelector(`#${side}_player_id_${idx}`);
|
||||
const defense = document.querySelector(`#${side}_defense_no_${idx}`);
|
||||
const orgPlayer = document.querySelector(`#org_${side}_player_id_${idx}`);
|
||||
const orgDefense = document.querySelector(`#org_${side}_defense_no_${idx}`);
|
||||
if (!player && !defense) continue;
|
||||
rows.push({
|
||||
idx,
|
||||
playerText: player ? player.options[player.selectedIndex]?.text || '' : '',
|
||||
playerValue: player ? player.value : '',
|
||||
defenseValue: defense ? defense.value : '',
|
||||
orgPlayerValue: orgPlayer ? orgPlayer.value : '',
|
||||
orgDefenseValue: orgDefense ? orgDefense.value : '',
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
return { home: buildSide('home'), away: buildSide('away') };
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def detect_change_side(
|
||||
half_inning: dict[str, Any], change_event: dict[str, Any], lineup_state: dict[str, Any],
|
||||
) -> str:
|
||||
"""교체가 일어난 팀(home/away) 추론"""
|
||||
actor_name = normalize_lineup_text(
|
||||
change_event.get("actor_name") or change_event.get("player_name") or ""
|
||||
)
|
||||
offense_side = "away" if half_inning.get("half") == "top" else "home"
|
||||
defense_side = "home" if offense_side == "away" else "away"
|
||||
|
||||
matched_sides: list[str] = []
|
||||
for side in ("home", "away"):
|
||||
for row in lineup_state.get(side, []):
|
||||
if normalize_lineup_text(row.get("playerText") or "") == actor_name:
|
||||
matched_sides.append(side)
|
||||
break
|
||||
|
||||
if len(matched_sides) == 1:
|
||||
return matched_sides[0]
|
||||
|
||||
actor_role = change_event.get("actor_role")
|
||||
if actor_role in {"batter", "대타", "대주자", "1루주자", "2루주자", "3루주자", "주자"}:
|
||||
return offense_side
|
||||
|
||||
return defense_side
|
||||
|
||||
|
||||
def find_change_row(side_rows: list[dict[str, Any]], change_event: dict[str, Any]) -> int | None:
|
||||
"""교체 대상 선수가 속한 라인업 행 인덱스 검색"""
|
||||
if change_event.get("bat_order") is not None:
|
||||
return int(change_event["bat_order"])
|
||||
|
||||
actor_name_raw = (
|
||||
change_event.get("actor_name")
|
||||
or change_event.get("player_name")
|
||||
or change_event.get("out_player")
|
||||
or ""
|
||||
)
|
||||
actor_name = normalize_lineup_text(actor_name_raw)
|
||||
|
||||
# 1단계: 완전 일치
|
||||
for row in side_rows:
|
||||
if normalize_lineup_text(row.get("playerText") or "") == actor_name:
|
||||
return int(row["idx"])
|
||||
|
||||
# 2단계: 부분 일치
|
||||
for row in side_rows:
|
||||
player_text = normalize_lineup_text(row.get("playerText") or "")
|
||||
if actor_name and (actor_name in player_text or player_text in actor_name):
|
||||
return int(row["idx"])
|
||||
|
||||
# 3단계: 역할(defenseValue) 일치
|
||||
actor_role = change_event.get("actor_role")
|
||||
pos_def_map = position_to_defense_no()
|
||||
if actor_role in pos_def_map:
|
||||
defense_no = pos_def_map[actor_role]
|
||||
for row in side_rows:
|
||||
if str(row.get("defenseValue") or "") == defense_no:
|
||||
return int(row["idx"])
|
||||
|
||||
# 4단계: 이름 혼재 경우 (예: "1루주자 문보경")
|
||||
if " " in actor_name_raw:
|
||||
potential_name = normalize_lineup_text(actor_name_raw.split()[-1])
|
||||
for row in side_rows:
|
||||
if normalize_lineup_text(row.get("playerText") or "") == potential_name:
|
||||
return int(row["idx"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_pitcher_row(side_rows: list[dict[str, Any]]) -> int | None:
|
||||
"""투수가 위치한 행(일반적으로 0번) 찾기"""
|
||||
pos_def_map = position_to_defense_no()
|
||||
pitcher_no = pos_def_map.get("투수")
|
||||
for row in side_rows:
|
||||
if str(row.get("defenseValue") or "") == pitcher_no:
|
||||
return int(row["idx"])
|
||||
return None
|
||||
|
||||
|
||||
def select_lineup_player(page: Page, side: str, row_idx: int, player_name: str) -> None:
|
||||
"""라인업 select 상자에서 선수명으로 선택"""
|
||||
select_id = f"#{side}_player_id_{row_idx}"
|
||||
options = page.locator(f"{select_id} option").evaluate_all(
|
||||
"""(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))"""
|
||||
)
|
||||
target_value = None
|
||||
normalized_target = normalize_lineup_text(player_name)
|
||||
for option in options:
|
||||
if normalize_lineup_text(option["text"]) == normalized_target:
|
||||
target_value = option["value"]
|
||||
break
|
||||
|
||||
if not target_value:
|
||||
raise ValueError(f"{side} {row_idx}번 행에서 선수 '{player_name}' 옵션을 찾지 못했습니다.")
|
||||
page.locator(select_id).select_option(value=target_value)
|
||||
|
||||
|
||||
def set_lineup_defense(page: Page, side: str, row_idx: int, position: str | None) -> None:
|
||||
"""라인업 수비 포지션 세팅"""
|
||||
if not position:
|
||||
return
|
||||
defense_no = position_to_defense_no().get(position)
|
||||
if not defense_no:
|
||||
return
|
||||
page.locator(f"#{side}_defense_no_{row_idx}").select_option(value=defense_no)
|
||||
|
||||
|
||||
def get_current_lineup_selection(page: Page, side: str, row_idx: int) -> tuple[str, str]:
|
||||
"""해당 행의 현재 선택된 선수와 수비 번호 값 반환"""
|
||||
player_value = page.locator(f"#{side}_player_id_{row_idx}").input_value()
|
||||
defense_value = page.locator(f"#{side}_defense_no_{row_idx}").input_value()
|
||||
return player_value, defense_value
|
||||
|
||||
|
||||
def get_target_player_value(page: Page, side: str, row_idx: int, player_name: str | None) -> str | None:
|
||||
if not player_name:
|
||||
return None
|
||||
select_id = f"#{side}_player_id_{row_idx}"
|
||||
options = page.locator(f"{select_id} option").evaluate_all(
|
||||
"""(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))"""
|
||||
)
|
||||
normalized_target = normalize_lineup_text(player_name)
|
||||
for option in options:
|
||||
if normalize_lineup_text(option["text"]) == normalized_target:
|
||||
return option["value"]
|
||||
return None
|
||||
|
||||
|
||||
def get_target_defense_value(position: str | None) -> str | None:
|
||||
if not position:
|
||||
return None
|
||||
return position_to_defense_no().get(position)
|
||||
|
||||
|
||||
def apply_change_event(
|
||||
page: Page,
|
||||
half_inning: dict[str, Any],
|
||||
change_event: dict[str, Any],
|
||||
change_cache: dict[str, tuple[str, int]],
|
||||
) -> None:
|
||||
"""선수 교체 이벤트를 라인업에 반영하고 저장"""
|
||||
change_event = normalize_change_event(change_event)
|
||||
actor_name_raw = change_event.get("actor_name") or change_event.get("player_name") or change_event.get("out_player") or ""
|
||||
cache_key = normalize_lineup_text(actor_name_raw)
|
||||
actor_name = normalize_lineup_text(actor_name_raw)
|
||||
|
||||
lineup_state = get_lineup_state(page)
|
||||
cached = change_cache.get(cache_key)
|
||||
side = cached[0] if cached else detect_change_side(half_inning, change_event, lineup_state)
|
||||
side_rows = lineup_state.get(side, [])
|
||||
row_idx = find_change_row(side_rows, change_event)
|
||||
|
||||
if row_idx is None and cached and cached[0] == side:
|
||||
row_idx = cached[1]
|
||||
|
||||
if row_idx is None:
|
||||
all_players = [normalize_lineup_text(r.get("playerText", "")) for r in side_rows]
|
||||
raise ValueError(
|
||||
f"{side} 교체 행을 찾지 못했습니다 "
|
||||
f"(Target: {actor_name}, Candidates: {all_players}): {change_event.get('text')}"
|
||||
)
|
||||
|
||||
# 확인창 무시
|
||||
page.evaluate("""() => { window.alert = () => {}; window.confirm = () => true; }""")
|
||||
|
||||
def trigger_lineup_save(idx: int):
|
||||
home_away_gb = 2 if side == "home" else 1
|
||||
page.evaluate(
|
||||
"""({ batterNo, homeAwayGb }) => {
|
||||
if (typeof window.f_lineup === 'function') {
|
||||
window.f_lineup(batterNo, homeAwayGb);
|
||||
}
|
||||
}""",
|
||||
{"batterNo": idx, "homeAwayGb": home_away_gb},
|
||||
)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
page.evaluate(
|
||||
"""({ side, idx }) => {
|
||||
const row = document.querySelector(`#${side}_player_id_${idx}`)?.parentElement?.parentElement;
|
||||
if (row) {
|
||||
const saveBtn = [...row.querySelectorAll("input[type=button], button, a")].find(el =>
|
||||
el.value === 'V' || el.innerText.includes('V') || el.innerText.includes('저장') || el.id.includes('save')
|
||||
);
|
||||
if (saveBtn) {
|
||||
saveBtn.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
{"side": side, "idx": idx}
|
||||
)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
# 병합 교체 (예: 포수→지명타자 전환 + 새 투수 등판)
|
||||
if change_event.get("change_type") == "merged_pitcher_substitution":
|
||||
actor_player_name = change_event.get("player_name") or change_event.get("actor_name")
|
||||
pitcher_in_player = change_event.get("pitcher_in_player") or change_event.get("in_player")
|
||||
pitcher_row_idx = find_pitcher_row(side_rows)
|
||||
if pitcher_row_idx is None or not pitcher_in_player:
|
||||
raise ValueError(f"투수 교체 행을 찾지 못했습니다: {change_event.get('text')}")
|
||||
|
||||
set_lineup_defense(page, side, row_idx, "지명타자")
|
||||
trigger_lineup_save(row_idx)
|
||||
trigger_lineup_save(row_idx + 1)
|
||||
|
||||
select_lineup_player(page, side, pitcher_row_idx, pitcher_in_player)
|
||||
set_lineup_defense(page, side, pitcher_row_idx, "투수")
|
||||
trigger_lineup_save(pitcher_row_idx)
|
||||
trigger_lineup_save(pitcher_row_idx + 1)
|
||||
|
||||
if actor_player_name:
|
||||
change_cache[normalize_lineup_text(actor_player_name)] = (side, int(row_idx))
|
||||
change_cache[normalize_lineup_text(pitcher_in_player)] = (side, int(pitcher_row_idx))
|
||||
return
|
||||
|
||||
# 단순 선수 교체
|
||||
if change_event.get("change_type") == "substitution":
|
||||
in_player = change_event.get("in_player")
|
||||
if not in_player:
|
||||
return
|
||||
select_lineup_player(page, side, row_idx, in_player)
|
||||
set_lineup_defense(page, side, row_idx, change_event.get("to_position"))
|
||||
trigger_lineup_save(row_idx)
|
||||
trigger_lineup_save(row_idx + 1)
|
||||
|
||||
out_player = change_event.get("out_player")
|
||||
if out_player:
|
||||
change_cache[normalize_lineup_text(out_player)] = (side, int(row_idx))
|
||||
change_cache[normalize_lineup_text(in_player)] = (side, int(row_idx))
|
||||
return
|
||||
|
||||
# 단순 수비 위치 변경
|
||||
if change_event.get("change_type") == "position_change":
|
||||
set_lineup_defense(page, side, row_idx, change_event.get("to_position"))
|
||||
trigger_lineup_save(row_idx)
|
||||
trigger_lineup_save(row_idx + 1)
|
||||
|
||||
player_name = change_event.get("player_name")
|
||||
if player_name:
|
||||
change_cache[normalize_lineup_text(player_name)] = (side, int(row_idx))
|
||||
return
|
||||
338
automation/page_helpers.py
Normal file
338
automation/page_helpers.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
automation/page_helpers.py — 공통 Playwright 유틸리티
|
||||
|
||||
라디오 버튼, select 박스, 가시성 판별 등 사이트 조작의 기초 함수.
|
||||
모든 automation 모듈이 이 모듈에 의존합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 라디오 버튼 조작
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_radio_map(page: Page, name: str) -> dict[str, str]:
|
||||
"""라디오 그룹의 eventName → value 맵 반환"""
|
||||
return page.evaluate(
|
||||
"""(name) => {
|
||||
const nodes = [...document.querySelectorAll(`input[type=radio][name='${name}']`)];
|
||||
const map = {};
|
||||
for (const node of nodes) {
|
||||
const eventName = (node.getAttribute('eventName') || '').trim();
|
||||
if (eventName) {
|
||||
map[eventName] = node.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}""",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def get_checked_event_name(page: Page, radio_name: str) -> str:
|
||||
"""현재 체크된 라디오의 eventName 반환"""
|
||||
return page.evaluate(
|
||||
"""(radioName) => {
|
||||
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']:checked`)];
|
||||
for (const node of nodes) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) {
|
||||
return node.getAttribute('eventName') || '';
|
||||
}
|
||||
}
|
||||
return nodes.length > 0 ? (nodes[0].getAttribute('eventName') || '') : '';
|
||||
}""",
|
||||
radio_name,
|
||||
)
|
||||
|
||||
|
||||
def set_radio_by_label(page: Page, radio_name: str, label: str) -> None:
|
||||
"""eventName이 label과 일치하는 라디오 클릭"""
|
||||
radios = page.locator(f"input[type=radio][name='{radio_name}']").all()
|
||||
target_radio = None
|
||||
|
||||
# 정확히 일치하는 라벨 우선
|
||||
for rb in radios:
|
||||
if rb.get_attribute("eventname") == label:
|
||||
target_radio = rb
|
||||
break
|
||||
|
||||
# 포함 관계로 탐색
|
||||
if not target_radio:
|
||||
for rb in radios:
|
||||
if label in (rb.get_attribute("eventname") or ""):
|
||||
target_radio = rb
|
||||
break
|
||||
|
||||
if target_radio:
|
||||
target_radio.click(force=True)
|
||||
|
||||
|
||||
def click_radio_by_label(page: Page, radio_name: str, label: str) -> None:
|
||||
"""라디오 버튼을 JS로 강제 클릭 (disabled 상태도 처리)"""
|
||||
page.evaluate(
|
||||
"""({ radioName, label }) => {
|
||||
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)];
|
||||
for (const node of nodes) {
|
||||
const eventName = (node.getAttribute('eventName') || '').trim();
|
||||
if (eventName === label) {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}""",
|
||||
{"radioName": radio_name, "label": label},
|
||||
)
|
||||
|
||||
|
||||
def find_visible_radio_by_label(page: Page, radio_name: str, label: str):
|
||||
"""가시적이고 활성화된 라디오를 찾아서 Locator 반환"""
|
||||
marker = page.evaluate(
|
||||
"""({ radioName, expectedLabel }) => {
|
||||
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)];
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) {
|
||||
continue;
|
||||
}
|
||||
const name = (node.getAttribute('eventName') || '').trim();
|
||||
if (name === expectedLabel) {
|
||||
const marker = `codex-radio-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
{"radioName": radio_name, "expectedLabel": label},
|
||||
)
|
||||
if marker:
|
||||
return page.locator(f"[data-codex-marker='{marker}']")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 가시성 유틸
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_last_visible_locator(page: Page, selector: str):
|
||||
"""selector 중 마지막으로 보이는 요소의 Locator 반환"""
|
||||
marker = page.evaluate(
|
||||
"""(selector) => {
|
||||
const nodes = [...document.querySelectorAll(selector)];
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden') {
|
||||
const marker = `codex-visible-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
selector,
|
||||
)
|
||||
if marker:
|
||||
return page.locator(f"[data-codex-marker='{marker}']")
|
||||
return None
|
||||
|
||||
|
||||
def get_last_visible_enabled_locator(page: Page, selector: str):
|
||||
"""selector 중 마지막으로 보이고 활성화된 요소의 Locator 반환"""
|
||||
marker = page.evaluate(
|
||||
"""(selector) => {
|
||||
const nodes = [...document.querySelectorAll(selector)];
|
||||
for (let i = nodes.length - 1; i >= 0; i -= 1) {
|
||||
const node = nodes[i];
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) {
|
||||
const marker = `codex-enabled-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
selector,
|
||||
)
|
||||
if marker:
|
||||
return page.locator(f"[data-codex-marker='{marker}']")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# select 박스 조작
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def set_select_by_partial_text(page: Page, selector: str, partial_text: str) -> None:
|
||||
"""텍스트 부분 일치로 select option 선택"""
|
||||
if not partial_text:
|
||||
return
|
||||
page.wait_for_selector(selector, timeout=3000)
|
||||
options = page.locator(f"{selector} option").all_text_contents()
|
||||
|
||||
target = partial_text.strip()
|
||||
for opt in options:
|
||||
if opt.strip() == target:
|
||||
page.select_option(selector, label=opt)
|
||||
return
|
||||
|
||||
target_clean = target.replace(" ", "").replace("/", ",").replace("-", ",")
|
||||
for opt in options:
|
||||
opt_clean = opt.strip().replace(" ", "").replace("/", ",").replace("-", ",")
|
||||
if target_clean in opt_clean or opt_clean in target_clean:
|
||||
page.select_option(selector, label=opt)
|
||||
return
|
||||
|
||||
print(f"DEBUG: '{selector}'에서 '{partial_text}'와 일치하는 옵션을 찾지 못함.")
|
||||
|
||||
|
||||
def set_select_by_text_or_value(page: Page, selector: str, desired: str) -> None:
|
||||
"""label 또는 value로 select option 선택"""
|
||||
locator = page.locator(selector)
|
||||
try:
|
||||
locator.select_option(label=desired)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
locator.select_option(value=desired)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
locator.select_option(index=0)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 디버그 오버레이 & 제어
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def show_debug_overlay(page: Page, lines: list[str]) -> None:
|
||||
"""페이지에 디버그 오버레이 표시"""
|
||||
page.evaluate(
|
||||
"""(lines) => {
|
||||
let box = document.querySelector('#codex-debug-overlay');
|
||||
if (!box) {
|
||||
window.codexControl = { paused: false, proceed: 0 };
|
||||
box = document.createElement('div');
|
||||
box.id = 'codex-debug-overlay';
|
||||
box.style.cssText = 'position:fixed;top:12px;right:12px;z-index:999999;background:rgba(0,0,0,0.82);color:#fff;padding:10px 12px;border-radius:8px;font-size:14px;line-height:1.5;max-width:360px;white-space:pre-wrap;box-shadow:0 4px 16px rgba(0,0,0,0.35)';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.style.cssText = 'margin-bottom:8px;display:flex;gap:8px';
|
||||
|
||||
const pauseBtn = document.createElement('button');
|
||||
pauseBtn.id = 'codex-pause-btn';
|
||||
pauseBtn.textContent = '일시정지';
|
||||
pauseBtn.onclick = () => {
|
||||
window.codexControl.paused = !window.codexControl.paused;
|
||||
pauseBtn.textContent = window.codexControl.paused ? '재개' : '일시정지';
|
||||
};
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.id = 'codex-next-btn';
|
||||
nextBtn.textContent = '다음';
|
||||
nextBtn.onclick = () => { window.codexControl.proceed += 1; };
|
||||
|
||||
controls.appendChild(pauseBtn);
|
||||
controls.appendChild(nextBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.id = 'codex-debug-body';
|
||||
|
||||
box.appendChild(controls);
|
||||
box.appendChild(body);
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
const body = document.querySelector('#codex-debug-body');
|
||||
if (body) { body.textContent = lines.join('\\n'); }
|
||||
}""",
|
||||
lines,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_operator_control(page: Page) -> None:
|
||||
"""일시정지/다음 버튼 대기"""
|
||||
state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }")
|
||||
last_proceed = state.get("proceed", 0)
|
||||
while True:
|
||||
state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }")
|
||||
if not state.get("paused"):
|
||||
return
|
||||
if state.get("proceed", 0) > last_proceed:
|
||||
page.evaluate(
|
||||
"(prev) => { if (window.codexControl && window.codexControl.proceed > prev) { window.codexControl.proceed = prev; } }",
|
||||
last_proceed,
|
||||
)
|
||||
return
|
||||
sleep(0.1)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 카운트 관리
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def advance_count(balls: int, strikes: int, pitch_result: str) -> tuple[int, int]:
|
||||
"""투구 결과에 따른 카운트 갱신"""
|
||||
if pitch_result in ("B",):
|
||||
return balls + 1, strikes
|
||||
if pitch_result in ("T", "S", "BS"):
|
||||
return balls, strikes + 1
|
||||
if pitch_result in ("F", "BF"):
|
||||
if strikes < 2:
|
||||
return balls, strikes + 1
|
||||
return balls, strikes
|
||||
|
||||
|
||||
def get_checked_batter_defense_type(page: Page) -> str:
|
||||
"""현재 선택된 타격 결과의 수비 유형 반환"""
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const checked = document.querySelector("input[type=radio][name='evt_batter']:checked");
|
||||
if (!checked) return '';
|
||||
return checked.getAttribute('defenseType') || '';
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def get_last_history_text(page: Page) -> str:
|
||||
"""사이트 내역(historyView)의 마지막 항목 텍스트 추출"""
|
||||
try:
|
||||
return (
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const nodes = document.querySelectorAll("div[name='historyView']");
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
return lastNode ? lastNode.textContent.trim() : '';
|
||||
}"""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_history_count(page: Page) -> int:
|
||||
"""기록 영역에 추가된 이벤트(historyView)의 총 개수 반환"""
|
||||
try:
|
||||
return int(
|
||||
page.evaluate(
|
||||
"""() => document.querySelectorAll("div[name='historyView']").length"""
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
96
automation/pitch_input.py
Normal file
96
automation/pitch_input.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
automation/pitch_input.py — 투구 입력
|
||||
|
||||
개별 투구의 구종, 구속, 투구결과를 사이트에 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.config_loader import pitch_type_map, pitch_result_map
|
||||
from automation.page_helpers import set_radio_by_label
|
||||
|
||||
|
||||
def get_pitch_runner_events(
|
||||
pitch: dict[str, Any], event: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""투구에 연결된 주루 이벤트 반환"""
|
||||
pitch_runner_events = list(pitch.get("runnerEvents") or [])
|
||||
if pitch_runner_events:
|
||||
return pitch_runner_events
|
||||
if event and event.get("runnerEvents"):
|
||||
return list(event.get("runnerEvents") or [])
|
||||
return []
|
||||
|
||||
|
||||
def set_pitch(page: Page, pitch: dict[str, Any], event: dict[str, Any] | None = None) -> None:
|
||||
"""투구 하나를 사이트에 입력 (구종 + 결과 + 구속)"""
|
||||
pt_map = pitch_type_map()
|
||||
pr_map = pitch_result_map()
|
||||
|
||||
pitch_type = pt_map.get(pitch.get("pitchType") or "")
|
||||
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
|
||||
normalized_text = pitch_result_text.replace(" ", "")
|
||||
|
||||
# 피치클락 투수위반 → 볼
|
||||
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
|
||||
pitch_result_text = "볼"
|
||||
|
||||
# 폭투/포일 체크
|
||||
runner_events = get_pitch_runner_events(pitch, event)
|
||||
is_wild_pitch = any(
|
||||
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
)
|
||||
is_passed_ball = any(
|
||||
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
)
|
||||
|
||||
if is_wild_pitch:
|
||||
pitch_result = "폭투-볼"
|
||||
elif is_passed_ball:
|
||||
pitch_result = "포일-볼"
|
||||
else:
|
||||
if "번트" in normalized_text and "헛스윙" in normalized_text:
|
||||
pitch_result = "번트시도-스트라이크"
|
||||
elif "번트" in normalized_text and "파울" in normalized_text:
|
||||
pitch_result = "번트-파울"
|
||||
else:
|
||||
pitch_result = pr_map.get(pitch_result_text)
|
||||
if not pitch_result and pitch.get("pitchResult") in {"BS", "V"}:
|
||||
pitch_result = "번트시도-스트라이크"
|
||||
if not pitch_result and pitch.get("pitchResult") == "BF":
|
||||
pitch_result = "번트-파울"
|
||||
if not pitch_result and "고의사구" in pitch_result_text:
|
||||
pitch_result = "고의사구"
|
||||
if not pitch_result and "파울플라이" in pitch_result_text and "실책" in pitch_result_text:
|
||||
pitch_result = "파울플라이-실책"
|
||||
|
||||
# 구종 입력
|
||||
if pitch_type:
|
||||
set_radio_by_label(page, "evt_ballType", pitch_type)
|
||||
|
||||
# 투구 결과 입력
|
||||
if pitch_result:
|
||||
set_radio_by_label(page, "evt_batter", pitch_result)
|
||||
|
||||
# 구속 입력
|
||||
speed_input = page.locator("#ballspeed")
|
||||
speed_input.fill(str(pitch.get("speedKmh") or 0))
|
||||
speed_input.evaluate("node => node.dispatchEvent(new Event('change', {bubbles:true}))")
|
||||
page.wait_for_timeout(50)
|
||||
|
||||
|
||||
def set_pitch_meta_only(page: Page, pitch: dict[str, Any]) -> None:
|
||||
"""구종/구속만 세팅 (인플레이 마지막 구에서 사용)
|
||||
|
||||
evt_batter를 건드리지 않아 팝업이 미리 열리는 것을 방지.
|
||||
"""
|
||||
pt_map = pitch_type_map()
|
||||
pitch_type = pt_map.get(pitch.get("pitchType") or "")
|
||||
if pitch_type:
|
||||
set_radio_by_label(page, "evt_ballType", pitch_type)
|
||||
page.locator("#ballspeed").fill(str(pitch.get("speedKmh") or 0))
|
||||
233
automation/review_input.py
Normal file
233
automation/review_input.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
automation/review_input.py — 비디오 판독/합의 판정 입력
|
||||
|
||||
비디오 판독 이벤트를 관리자 사이트 팝업에 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.review_parser import parse_review_event_text
|
||||
from automation.page_helpers import (
|
||||
set_select_by_partial_text,
|
||||
set_select_by_text_or_value,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""텍스트 기반 이벤트 파싱 및 정규화"""
|
||||
has_results = review_event.get("beforeResult") is not None and review_event.get("finalResult") is not None
|
||||
if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results:
|
||||
return review_event
|
||||
|
||||
text = review_event.get("text") or ""
|
||||
parsed = parse_review_event_text(text)
|
||||
parsed.update({k: v for k, v in review_event.items() if k not in parsed})
|
||||
return parsed
|
||||
|
||||
|
||||
def _open_challenge_popup(page: Page) -> Page:
|
||||
"""비디오 판독 팝업 열기"""
|
||||
with page.expect_popup(timeout=5000) as popup_info:
|
||||
page.locator("#challengeBtn").click()
|
||||
popup = popup_info.value
|
||||
popup.wait_for_load_state("domcontentloaded")
|
||||
popup.wait_for_selector("#requestInning_0", timeout=5000)
|
||||
return popup
|
||||
|
||||
|
||||
def _select_review_final_result(
|
||||
popup: Page, row_index: int, review_item: str, final_result: str | None,
|
||||
) -> None:
|
||||
"""판독 결과 선택"""
|
||||
from core.config_loader import review_result_groups
|
||||
groups = review_result_groups()
|
||||
|
||||
# 설정에 매핑된 그룹/기본값 찾기
|
||||
group_info = groups.get(review_item)
|
||||
if group_info:
|
||||
group_key = group_info["type"]
|
||||
default_a = group_info["options"][0]
|
||||
else:
|
||||
group_key = "type3"
|
||||
default_a = "인정"
|
||||
|
||||
result_value = final_result or default_a
|
||||
|
||||
# 셀렉터 찾기 시도
|
||||
select_selector = f"#finalResult_{group_key}_{row_index}"
|
||||
if not popup.locator(select_selector).count():
|
||||
select_selector = f"#finalResult_{group_key[-1]}_{row_index}"
|
||||
if not popup.locator(select_selector).count():
|
||||
select_selector = f"#finalResult_type{group_key[-1]}_{row_index}"
|
||||
|
||||
set_select_by_text_or_value(popup, select_selector, result_value)
|
||||
|
||||
# JS 강제 이벤트 발생
|
||||
try:
|
||||
popup.locator(f"#finalResult_{row_index}").evaluate(
|
||||
"""(node, value) => {
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}""",
|
||||
result_value,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _fill_review_row(popup: Page, row_index: int, review_event: dict[str, Any]) -> None:
|
||||
"""팝업의 한 행에 판독 이벤트 입력"""
|
||||
request_inning = review_event.get("requestInningLabel") or "1초"
|
||||
request_team = review_event.get("requestTeam") or popup.locator(f"#requestTeamId_{row_index} option").first.text_content() or ""
|
||||
review_item = review_event.get("reviewItem") or "기타"
|
||||
final_result = review_event.get("finalResult")
|
||||
is_success_val = "Y" if (review_event.get("isSuccess") == "성공") else "N"
|
||||
|
||||
popup.wait_for_selector(f"#requestInning_{row_index}", timeout=3000)
|
||||
set_select_by_text_or_value(popup, f"#requestInning_{row_index}", request_inning)
|
||||
|
||||
try:
|
||||
set_select_by_partial_text(popup, f"#requestTeamId_{row_index}", request_team)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if popup.is_closed(): return
|
||||
try:
|
||||
set_select_by_partial_text(popup, f"#forWhat_{row_index}", review_item)
|
||||
except Exception:
|
||||
set_select_by_text_or_value(popup, f"#forWhat_{row_index}", "기타")
|
||||
|
||||
popup.wait_for_timeout(100)
|
||||
|
||||
if popup.is_closed(): return
|
||||
_select_review_final_result(popup, row_index, review_item, final_result)
|
||||
|
||||
if popup.is_closed(): return
|
||||
set_select_by_text_or_value(popup, f"#isSuccess_{row_index}", is_success_val)
|
||||
|
||||
|
||||
def _append_review_row(popup: Page) -> int:
|
||||
"""신규 판독 행 추가"""
|
||||
before_count = popup.locator("select[id^='requestInning_']").count()
|
||||
popup.get_by_role("button", name="신규추가").click()
|
||||
popup.wait_for_function(
|
||||
"""(expectedCount) => {
|
||||
return document.querySelectorAll("select[id^='requestInning_']").length > expectedCount;
|
||||
}""",
|
||||
arg=before_count,
|
||||
timeout=3000,
|
||||
)
|
||||
row_index = popup.evaluate(
|
||||
"""() => {
|
||||
const ids = [...document.querySelectorAll("select[id^='requestInning_']")]
|
||||
.map((el) => el.id)
|
||||
.map((id) => Number(id.split("_").pop()))
|
||||
.filter((num) => Number.isFinite(num));
|
||||
return ids.length ? Math.max(...ids) : 0;
|
||||
}"""
|
||||
)
|
||||
popup.wait_for_selector(f"#requestInning_{row_index}", timeout=5000)
|
||||
return int(row_index)
|
||||
|
||||
|
||||
def _can_reuse_initial_review_row(popup: Page) -> bool:
|
||||
"""초기화된 0번 행 재사용 가능 여부"""
|
||||
try:
|
||||
row_count = popup.locator("select[id^='requestInning_']").count()
|
||||
if row_count != 1:
|
||||
return False
|
||||
|
||||
hidden_id = (popup.locator("#id_0").input_value() or "").strip()
|
||||
if hidden_id:
|
||||
return False
|
||||
|
||||
request_inning = popup.locator("#requestInning_0").input_value()
|
||||
request_team = popup.locator("#requestTeamId_0").input_value()
|
||||
review_item = popup.locator("#forWhat_0").input_value()
|
||||
final_result = (popup.locator("#finalResult_0").input_value() or "").strip()
|
||||
is_success = popup.locator("#isSuccess_0").input_value()
|
||||
|
||||
return (
|
||||
request_inning == "1"
|
||||
and review_item == "홈런타구 페어 파울"
|
||||
and final_result == "페어"
|
||||
and is_success == "Y"
|
||||
and bool(request_team)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _save_review_popup(popup: Page) -> None:
|
||||
"""팝업 저장 및 닫기"""
|
||||
if popup.is_closed():
|
||||
return
|
||||
|
||||
popup.evaluate("""() => {
|
||||
window.confirm = () => true;
|
||||
window.alert = () => {};
|
||||
}""")
|
||||
|
||||
try:
|
||||
with popup.expect_response(
|
||||
re.compile(r"/manager/game/status/challenge/ajax"),
|
||||
timeout=3000,
|
||||
) as _:
|
||||
save_btn = popup.locator("#saveLog")
|
||||
if save_btn.count() > 0:
|
||||
save_btn.click(force=True)
|
||||
else:
|
||||
popup.evaluate("""() => {
|
||||
const btn = document.querySelector('#btnAdd')
|
||||
|| document.querySelector('#btnSave')
|
||||
|| [...document.querySelectorAll('button, a')].find(
|
||||
el => el.innerText.includes('입력완료') || el.innerText.includes('저장')
|
||||
);
|
||||
if (btn) btn.click();
|
||||
}""")
|
||||
except Exception:
|
||||
try:
|
||||
popup.evaluate("""() => {
|
||||
const btn = document.querySelector('#saveLog')
|
||||
|| document.querySelector('#btnAdd');
|
||||
if (btn) btn.click();
|
||||
}""")
|
||||
popup.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not popup.is_closed():
|
||||
popup.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_review_events(page: Page, review_events: list[dict[str, Any]]) -> None:
|
||||
"""비디오 판독 이벤트 전체 처리 파이프라인"""
|
||||
normalized_events = [_normalize_review_event(event) for event in (review_events or [])]
|
||||
if not normalized_events:
|
||||
return
|
||||
|
||||
popup = _open_challenge_popup(page)
|
||||
reuse_initial_row = _can_reuse_initial_review_row(popup)
|
||||
|
||||
for index, review_event in enumerate(normalized_events):
|
||||
if index == 0 and reuse_initial_row:
|
||||
row_index = 0
|
||||
else:
|
||||
row_index = _append_review_row(popup)
|
||||
|
||||
_fill_review_row(popup, row_index, review_event)
|
||||
|
||||
_save_review_popup(popup)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
try:
|
||||
page.bring_to_front()
|
||||
except Exception:
|
||||
pass
|
||||
271
automation/runner_input.py
Normal file
271
automation/runner_input.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
automation/runner_input.py — 주루 입력
|
||||
|
||||
주루 이벤트(진루, 도루, 견제, 실책 진루 등)를 관리자 사이트에 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from time import time, sleep
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from core.field_calculator import is_double_play_result, extract_error_position
|
||||
from core.runner_classifier import infer_runner_action_label
|
||||
from automation.page_helpers import (
|
||||
get_last_visible_enabled_locator,
|
||||
set_radio_by_label,
|
||||
get_checked_event_name,
|
||||
)
|
||||
from automation.defense_popup import (
|
||||
fill_error_defense_popup,
|
||||
fill_runner_out_defense,
|
||||
)
|
||||
|
||||
|
||||
def _split_complex_runner_event(
|
||||
runner_event: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""복합 주루 이벤트(예: 1루주자 2루까지 진루 / 홈까지 들어오다 아웃) 분할"""
|
||||
text = runner_event.get("text") or ""
|
||||
|
||||
if "/" not in text or ("실책" not in text and "아웃" not in text):
|
||||
return runner_event, None
|
||||
|
||||
parts = [p.strip() for p in text.split("/") if p.strip()]
|
||||
if len(parts) < 2:
|
||||
return runner_event, None
|
||||
|
||||
def extract_base(t: str) -> int | None:
|
||||
m = re.search(r"([123])루", t)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
# 1차 이벤트
|
||||
primary = dict(runner_event)
|
||||
primary["text"] = parts[0]
|
||||
intermediate_to = extract_base(parts[0])
|
||||
if intermediate_to:
|
||||
primary["toBase"] = intermediate_to
|
||||
|
||||
# 2차 이벤트
|
||||
secondary = dict(runner_event)
|
||||
secondary["fromBase"] = primary.get("toBase")
|
||||
secondary["text"] = parts[1]
|
||||
|
||||
if "실책" in parts[1]:
|
||||
secondary["type"] = "error_advance"
|
||||
elif "태그" in parts[1]:
|
||||
secondary["type"] = "tag_out"
|
||||
elif "포스" in parts[1]:
|
||||
secondary["type"] = "force_out"
|
||||
else:
|
||||
secondary["type"] = "out"
|
||||
|
||||
return primary, secondary
|
||||
|
||||
|
||||
def _open_runner_area(page: Page, from_base: int, area_type: int) -> None:
|
||||
"""주루 영역(1: 진루, 2: 액션) 열기"""
|
||||
function_name = f"changRunnerArea{from_base}"
|
||||
page.evaluate(
|
||||
"""({ functionName, areaType }) => {
|
||||
const fn = window[functionName];
|
||||
if (typeof fn === 'function') {
|
||||
fn(areaType);
|
||||
}
|
||||
}""",
|
||||
{"functionName": function_name, "areaType": area_type},
|
||||
)
|
||||
|
||||
deadline = time() + 2
|
||||
radio_name = f"evt_runner_{from_base}"
|
||||
advance_name = f"dat_evt_runner_{from_base}_advance"
|
||||
|
||||
while time() < deadline:
|
||||
ready = page.evaluate(
|
||||
"""({ radioName, advanceName }) => {
|
||||
const hasEnabledVisibleRadio = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)].some((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
|
||||
});
|
||||
const hasEnabledVisibleAdvance = [...document.querySelectorAll(`input[type=radio][name='${advanceName}']`)].some((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled);
|
||||
});
|
||||
return hasEnabledVisibleRadio || hasEnabledVisibleAdvance;
|
||||
}""",
|
||||
{"radioName": radio_name, "advanceName": advance_name},
|
||||
)
|
||||
if ready:
|
||||
return
|
||||
sleep(0.1)
|
||||
|
||||
|
||||
def _set_runner_action(page: Page, from_base: int, label: str) -> None:
|
||||
"""주자 액션(일반진루, 도루성공 등) 라디오 버튼 세팅"""
|
||||
radio_name = f"evt_runner_{from_base}"
|
||||
locator = page.evaluate(
|
||||
r"""({ radioName, eventName }) => {
|
||||
const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)];
|
||||
// 1단계: eventName 속성으로 매칭
|
||||
for (const node of nodes) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(node);
|
||||
if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) {
|
||||
continue;
|
||||
}
|
||||
if ((node.getAttribute('eventName') || '') === eventName) {
|
||||
const marker = `codex-runner-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
// 2단계: 텍스트로 부분 매칭
|
||||
for (const node of nodes) {
|
||||
let text = '';
|
||||
let p = node.parentElement;
|
||||
if (p) text = p.textContent.trim();
|
||||
if (!text && node.nextSibling) text = node.nextSibling.textContent || '';
|
||||
|
||||
if (eventName && text.replace(/\s/g, '').includes(eventName.replace(/\s/g, ''))) {
|
||||
const marker = `codex-runner-${Math.random().toString(36).slice(2)}`;
|
||||
node.setAttribute('data-codex-marker', marker);
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}""",
|
||||
{"radioName": radio_name, "eventName": label},
|
||||
)
|
||||
|
||||
if locator:
|
||||
candidate = page.locator(f"[data-codex-marker='{locator}']")
|
||||
for _ in range(5):
|
||||
try:
|
||||
candidate.click(force=True, timeout=500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
candidate.evaluate(
|
||||
"""(node) => {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.wait_for_timeout(100)
|
||||
if get_checked_event_name(page, radio_name) == label:
|
||||
return
|
||||
else:
|
||||
for _ in range(5):
|
||||
set_radio_by_label(page, radio_name, label)
|
||||
page.wait_for_timeout(50)
|
||||
if get_checked_event_name(page, radio_name) == label:
|
||||
return
|
||||
|
||||
|
||||
def _set_runner_advance(page: Page, from_base: int, to_base: int | None) -> None:
|
||||
"""주자 최종 목적지 루 세팅"""
|
||||
if to_base is None:
|
||||
return
|
||||
selector = f"input[name='dat_evt_runner_{from_base}_advance'][value='{to_base}']"
|
||||
deadline = time() + 3
|
||||
locator = None
|
||||
|
||||
while time() < deadline:
|
||||
locator = get_last_visible_enabled_locator(page, selector)
|
||||
if locator is not None:
|
||||
break
|
||||
sleep(0.1)
|
||||
|
||||
if locator is None:
|
||||
fallback = page.locator(selector)
|
||||
if fallback.count():
|
||||
locator = fallback.last
|
||||
else:
|
||||
return # timeout
|
||||
|
||||
try:
|
||||
locator.click(force=True)
|
||||
except Exception:
|
||||
locator.evaluate(
|
||||
"""(node) => {
|
||||
node.disabled = false;
|
||||
node.checked = true;
|
||||
node.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def set_runner_events(
|
||||
page: Page, event: dict[str, Any], runner_events: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""모든 주루 이벤트를 처리하고 지연 처리할 이벤트(late_events) 반환"""
|
||||
if runner_events is None:
|
||||
runner_events = (event.get("runnerEvents") or []).copy()
|
||||
|
||||
late_events = []
|
||||
filtered_events = []
|
||||
for re_item in runner_events:
|
||||
primary, secondary = _split_complex_runner_event(re_item)
|
||||
filtered_events.append(primary)
|
||||
if secondary:
|
||||
late_events.append(secondary)
|
||||
|
||||
for runner_event in filtered_events:
|
||||
from_base = runner_event.get("fromBase")
|
||||
if from_base not in {1, 2, 3}:
|
||||
continue
|
||||
|
||||
label = infer_runner_action_label(event, runner_event)
|
||||
if not label:
|
||||
continue
|
||||
|
||||
if any(k in label for k in ["도루", "견제", "폭투", "포일", "아웃"]):
|
||||
area_type = 2
|
||||
else:
|
||||
area_type = 1
|
||||
|
||||
if any(k in label for k in ["일반 진루", "볼넷 진루", "수비 실책"]):
|
||||
area_type = 1
|
||||
|
||||
_open_runner_area(page, from_base, area_type)
|
||||
_set_runner_action(page, from_base, label)
|
||||
|
||||
runner_text = runner_event.get("text") or ""
|
||||
is_error_related_label = (
|
||||
(label and "실책" in label)
|
||||
or label in {"견제 에러", "수비 실책", "도루성공&실책"}
|
||||
or "실책" in runner_text
|
||||
)
|
||||
if is_error_related_label:
|
||||
if extract_error_position(runner_text):
|
||||
fill_error_defense_popup(page, runner_text)
|
||||
|
||||
if label in {"태그아웃", "도루시도 아웃", "포스아웃"}:
|
||||
fill_runner_out_defense(page, runner_text)
|
||||
|
||||
page.wait_for_timeout(150)
|
||||
_set_runner_advance(page, from_base, runner_event.get("toBase"))
|
||||
|
||||
try:
|
||||
extra_advance = runner_event.get("extra_advance")
|
||||
if extra_advance and extra_advance > 0:
|
||||
locator = get_last_visible_enabled_locator(page, f"select[name='runner{from_base}_running_add']")
|
||||
if locator is not None:
|
||||
locator.select_option(value=str(extra_advance))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.wait_for_timeout(150)
|
||||
|
||||
return late_events
|
||||
149
cli.py
Normal file
149
cli.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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. 관리자 사이트 기록 자동 입력 (record)")
|
||||
print("3. 크롤링 + 기록 입력 한 번에 실행 (crawl & record)")
|
||||
|
||||
choice = input("\n실행할 작업을 선택하세요 (1/2/3) [3]: ").strip() or "3"
|
||||
if choice not in {"1", "2", "3"}:
|
||||
print("잘못된 선택입니다.")
|
||||
return 1
|
||||
|
||||
game_id = input("경기 ID를 입력하세요 (예: 20260501NCLG02026): ").strip()
|
||||
if not game_id:
|
||||
print("경기 ID가 필요합니다.")
|
||||
return 1
|
||||
game_id = "".join(game_id.split())
|
||||
|
||||
base_url = ""
|
||||
user_data_dir = ""
|
||||
if choice in {"2", "3"}:
|
||||
base_url = input("기록 사이트 기본 URL을 입력하세요 (엔터 시 site.txt 자동 참조): ").strip()
|
||||
if not base_url:
|
||||
site_txt = Path("site.txt")
|
||||
if site_txt.exists():
|
||||
lines = site_txt.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}")
|
||||
|
||||
if not base_url:
|
||||
base_url = input("URL을 찾을 수 없습니다. 직접 입력하세요: ").strip()
|
||||
if not base_url:
|
||||
print("URL이 필요합니다.")
|
||||
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",
|
||||
)
|
||||
|
||||
if choice in {"1", "3"}:
|
||||
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"}:
|
||||
report_path = Path(args.output_dir) / f"{args.game_id}_report.json"
|
||||
report = load_report(report_path)
|
||||
print(f"\n[{args.game_id}] 관리자 사이트 자동 입력 시작...")
|
||||
with sync_playwright() as playwright:
|
||||
run_record(playwright, args, report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) == 1:
|
||||
return interactive_mode()
|
||||
|
||||
args = _parse_args()
|
||||
|
||||
if args.game_id:
|
||||
args.game_id = "".join(args.game_id.split())
|
||||
|
||||
if args.command == "record":
|
||||
report_path = Path(args.report_path) if args.report_path else Path("output") / f"{args.game_id}_report.json"
|
||||
report = load_report(report_path)
|
||||
with sync_playwright() as playwright:
|
||||
run_record(playwright, args, report)
|
||||
return 0
|
||||
|
||||
if args.command == "crawl":
|
||||
from crawler.report_builder import build_report, filter_report, save_report
|
||||
print(f"[{args.game_id}] 데이터 크롤링 시작...")
|
||||
report = build_report(args.game_id, start_inning=args.start_inning, end_inning=args.end_inning)
|
||||
filtered = filter_report(
|
||||
report,
|
||||
lineup_only=getattr(args, "lineup_only", False),
|
||||
start_inning=args.start_inning,
|
||||
end_inning=args.end_inning
|
||||
)
|
||||
out_path = save_report(filtered, Path(args.output_dir))
|
||||
print(f"✅ 크롤링 및 리포트 저장 완료: {out_path}")
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
52
commands/base.py
Normal file
52
commands/base.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
commands/base.py — 공통 CLI 명령어 유틸리티
|
||||
|
||||
브라우저 컨텍스트 초기화, 리포트 파일 로드 등 여러 명령어에서 공통으로
|
||||
사용하는 기능을 제공합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Playwright, BrowserContext
|
||||
|
||||
|
||||
def launch_browser_context(
|
||||
playwright: Playwright, user_data_dir: str | None, channel: str | None, headless: bool
|
||||
) -> BrowserContext:
|
||||
"""공용 Playwright 브라우저 컨텍스트 생성"""
|
||||
if user_data_dir:
|
||||
return playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=user_data_dir,
|
||||
channel=channel,
|
||||
headless=headless,
|
||||
args=["--disable-blink-features=AutomationControlled"],
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
browser = playwright.chromium.launch(channel=channel, headless=headless)
|
||||
return browser.new_context(viewport={"width": 1280, "height": 800})
|
||||
|
||||
|
||||
def load_report(path: Path) -> dict[str, Any]:
|
||||
"""JSON 리포트 파일 로드"""
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"리포트 파일을 찾을 수 없습니다: {path}")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
"""명령행 인자에 브라우저/설정 관련 공통 옵션 추가"""
|
||||
parser.add_argument("--base-url", required=True, help="기록 시스템 기본 URL")
|
||||
parser.add_argument("--game-id", required=True, help="크롤링할 네이버 게임 ID (예: 20260501NCLG02026)")
|
||||
parser.add_argument("--report-path", help="로컬 리포트 JSON 경로 (기본값: output/{game_id}_report.json)")
|
||||
parser.add_argument("--manager-game-no", help="관리자 사이트의 게임번호 (생략 시 목록에서 검색)")
|
||||
parser.add_argument("--user-data-dir", help="Chrome 사용자 프로필 경로 (로그인 유지용)")
|
||||
parser.add_argument("--channel", default="chrome", help="브라우저 채널 (기본값: chrome)")
|
||||
parser.add_argument("--headless", action="store_true", help="브라우저 숨김 모드 실행")
|
||||
parser.add_argument("--close", action="store_true", help="작업 완료 후 브라우저 닫기")
|
||||
parser.add_argument("--no-write", dest="write_events", action="store_false", help="실제 입력은 하지 않고 시뮬레이션만 수행")
|
||||
parser.add_argument("--job-id", help="DB 로깅용 작업 ID (UUID)")
|
||||
556
commands/record.py
Normal file
556
commands/record.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
commands/record.py — 게임 전체 기록 (Main Command)
|
||||
|
||||
JSON 리포트를 읽고 관리자 사이트의 폼을 제어하여
|
||||
경기 전체를 순차적으로 입력합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from time import time, sleep
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Playwright, Page
|
||||
|
||||
from core.normalizer import (
|
||||
normalize_game_type,
|
||||
normalize_stadium_name,
|
||||
normalize_team_name,
|
||||
)
|
||||
from core.pitch_classifier import infer_batter_result_label, get_last_pitch_result_text
|
||||
from core.field_calculator import extract_defense_sequence, extract_error_position, is_throwing_error, is_error_result
|
||||
from automation.page_helpers import (
|
||||
get_last_visible_locator,
|
||||
get_checked_event_name,
|
||||
set_radio_by_label,
|
||||
show_debug_overlay,
|
||||
wait_for_operator_control,
|
||||
get_checked_batter_defense_type,
|
||||
get_history_count,
|
||||
)
|
||||
from automation.defense_popup import fill_error_defense_popup, click_defense_button_robustly
|
||||
from automation.pitch_input import set_pitch, set_pitch_meta_only, get_pitch_runner_events
|
||||
from automation.batter_input import set_batter_result_type, set_batter_advancement, set_hit_ball_and_defense
|
||||
from automation.runner_input import set_runner_events
|
||||
from automation.lineup_input import apply_change_event
|
||||
from automation.review_input import record_review_events
|
||||
from commands.base import launch_browser_context, load_report, add_common_arguments
|
||||
|
||||
_DEFENSE_CLEAR_JS = """
|
||||
() => {
|
||||
document.querySelectorAll("input[name='defenseNumberBtn']").forEach(el => { el.checked = false; });
|
||||
document.querySelectorAll("input[name='hitBallType']").forEach(el => { el.checked = false; });
|
||||
|
||||
const datIds = [
|
||||
"putout", "assist", "error", "upstruction",
|
||||
"dat_putout_hitter", "dat_assist_hitter", "dat_error_hitter", "dat_upstruction_hitter",
|
||||
"dat_putout_runner1", "dat_assist_runner1", "dat_error_runner1", "dat_upstruction_runner1",
|
||||
"dat_putout_runner2", "dat_assist_runner2", "dat_error_runner2", "dat_upstruction_runner2",
|
||||
"dat_putout_runner3", "dat_assist_runner3", "dat_error_runner3", "dat_upstruction_runner3",
|
||||
"dat_error_type", "dat_error_type1", "dat_error_type2", "dat_error_type3",
|
||||
"dat_multiplay_type", "multiplay_type",
|
||||
"hitBallXY", "hitBallDistance",
|
||||
"dat_hitball_speed", "dat_hitball_type", "dat_hitball_x", "dat_hitball_y", "dat_hitball_xy", "dat_hitball_distance",
|
||||
"hitball_speed", "hitball_type", "hitball_xy", "hitball_distance"
|
||||
];
|
||||
datIds.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = "";
|
||||
});
|
||||
|
||||
document.querySelectorAll("input[name='hitBallType'], [id^='hitBallSpeed'], [id^='hitBallType']").forEach(el => {
|
||||
if (el.type === 'radio' || el.type === 'checkbox') {
|
||||
el.checked = false;
|
||||
} else {
|
||||
el.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def clear_defense_selections(page: Page) -> None:
|
||||
page.evaluate(_DEFENSE_CLEAR_JS)
|
||||
|
||||
|
||||
def is_simple_terminal_result_type(result_type: str) -> bool:
|
||||
return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"}
|
||||
|
||||
|
||||
def advance_count(balls: int, strikes: int, pitch_result: str) -> tuple[int, int]:
|
||||
if pitch_result == "B":
|
||||
return min(balls + 1, 3), strikes
|
||||
if pitch_result in {"T", "S"}:
|
||||
return balls, min(strikes + 1, 2)
|
||||
if pitch_result == "F":
|
||||
return balls, strikes if strikes >= 2 else strikes + 1
|
||||
return balls, strikes
|
||||
|
||||
|
||||
def find_status_href(page: Page, report: dict[str, Any], manager_game_no: str | None) -> str:
|
||||
game_info = report["game_info"]
|
||||
target_date = game_info["date"]
|
||||
target_stadium = normalize_stadium_name(game_info["stadium"])
|
||||
target_home_team = normalize_team_name(game_info["home_team"])
|
||||
target_away_team = normalize_team_name(game_info["away_team"])
|
||||
target_game_type = normalize_game_type(game_info["game_type"])
|
||||
|
||||
rows: list[dict[str, Any]] = page.locator("table.gclist tr").evaluate_all(
|
||||
"""(rows) => rows.slice(1).map((row) => {
|
||||
const cells = [...row.cells].map((cell) => cell.innerText.trim());
|
||||
const statusLink = [...row.querySelectorAll('a')].find((anchor) => anchor.textContent.trim() === '게임기록');
|
||||
return {
|
||||
gameNo: cells[0] || '',
|
||||
date: cells[1] || '',
|
||||
gameType: cells[2] || '',
|
||||
stadium: cells[3] || '',
|
||||
homeTeam: cells[4] || '',
|
||||
awayTeam: cells[5] || '',
|
||||
href: statusLink ? statusLink.getAttribute('href') : '',
|
||||
};
|
||||
})"""
|
||||
)
|
||||
|
||||
if manager_game_no:
|
||||
matched = next((row for row in rows if row["gameNo"] == str(manager_game_no)), None)
|
||||
if not matched or not matched["href"] or matched["href"].startswith("javascript:"):
|
||||
raise ValueError(f"관리자 게임번호 {manager_game_no} 행의 게임기록 링크를 찾지 못했습니다.")
|
||||
return matched["href"]
|
||||
|
||||
candidates = [
|
||||
row for row in rows if row["href"] and not row["href"].startswith("javascript:")
|
||||
and row["date"] == target_date
|
||||
and (not row["gameType"] or normalize_game_type(row["gameType"]) == target_game_type)
|
||||
and normalize_stadium_name(row["stadium"]) == target_stadium
|
||||
and normalize_team_name(row["homeTeam"]) == target_home_team
|
||||
and normalize_team_name(row["awayTeam"]) == target_away_team
|
||||
]
|
||||
if not candidates:
|
||||
print("\n=== 게임 매칭 실패 디버그 정보 ===")
|
||||
print(f"Target: Date='{target_date}', Type='{target_game_type}', Stadium='{target_stadium}', Home='{target_home_team}', Away='{target_away_team}'")
|
||||
print("Rows parsed from table:")
|
||||
for r in rows:
|
||||
print(f" - Date='{r['date']}', Type='{r['gameType']}'(norm: '{normalize_game_type(r['gameType'])}'), Stadium='{r['stadium']}'(norm: '{normalize_stadium_name(r['stadium'])}'), Home='{r['homeTeam']}'(norm: '{normalize_team_name(r['homeTeam'])}'), Away='{r['awayTeam']}'(norm: '{normalize_team_name(r['awayTeam'])}')")
|
||||
raise ValueError("목록에서 일치하는 게임기록 링크를 찾지 못했습니다.")
|
||||
return candidates[0]["href"]
|
||||
|
||||
|
||||
def open_game_status_page(page: Page, base_url: str, report: dict[str, Any], manager_game_no: str | None) -> None:
|
||||
if manager_game_no:
|
||||
page.goto(f"{base_url}/manager/game/status?game_no={manager_game_no}", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#eventWriteBtn", timeout=10000)
|
||||
return
|
||||
page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("table.gclist", timeout=10000)
|
||||
status_href = find_status_href(page, report, manager_game_no)
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.locator(f"a[href='{status_href}']").first.click()
|
||||
page.wait_for_selector("#eventWriteBtn", timeout=10000)
|
||||
|
||||
|
||||
def _try_log_pitch(log_info: dict[str, Any] | None, is_success: bool, error_code: str, error_detail: str, duration: float) -> None:
|
||||
if log_info and log_info.get("job_id"):
|
||||
try:
|
||||
from db_logging import log_pitch
|
||||
log_pitch(
|
||||
log_info["job_id"], log_info.get("inning", ""), log_info.get("batter", ""),
|
||||
log_info.get("pitch_no", 0), log_info.get("target_value", ""), log_info.get("selected_value", ""),
|
||||
is_success, error_code, error_detail, duration
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _try_log_event(log_info: dict[str, Any] | None, is_success: bool, error_msg: str = "") -> None:
|
||||
if log_info and log_info.get("job_id"):
|
||||
try:
|
||||
from db_logging import log_event
|
||||
log_event(
|
||||
log_info["job_id"], log_info.get("inning", ""), log_info.get("event_type", ""),
|
||||
log_info.get("target_player", ""), log_info.get("actual_player", ""),
|
||||
is_success, error_msg
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def submit_input_complete(page: Page, debug_label: str = "", clear_defense: bool = False, log_info: dict[str, Any] | None = None) -> None:
|
||||
t0 = time()
|
||||
try:
|
||||
page.evaluate("""() => {
|
||||
window.confirm = () => true;
|
||||
window.alert = () => {};
|
||||
const defenseDiv = document.querySelector('#defenseDiv');
|
||||
if (defenseDiv && defenseDiv.style.display !== 'none') {
|
||||
const btnAdd = document.querySelector('#btnAdd');
|
||||
if (btnAdd) btnAdd.click();
|
||||
}
|
||||
}""")
|
||||
|
||||
if clear_defense:
|
||||
clear_defense_selections(page)
|
||||
|
||||
prev_history = get_history_count(page)
|
||||
for i in range(40):
|
||||
curr_history = get_history_count(page)
|
||||
if curr_history > prev_history:
|
||||
page.wait_for_timeout(30)
|
||||
_try_log_pitch(log_info, True, "", "", time() - t0)
|
||||
return
|
||||
|
||||
if i % 8 == 0:
|
||||
submit_btn = get_last_visible_locator(page, "#eventWriteBtn")
|
||||
if not submit_btn:
|
||||
submit_btn = page.get_by_role("button", name="입력완료").last
|
||||
|
||||
if submit_btn:
|
||||
try:
|
||||
submit_btn.click(force=True, timeout=500)
|
||||
except Exception:
|
||||
page.evaluate("document.querySelector('#eventWriteBtn')?.click() || [...document.querySelectorAll('a, button')].find(el => el.innerText.includes('입력완료'))?.click()")
|
||||
|
||||
page.wait_for_timeout(50)
|
||||
page.evaluate("() => { window.confirm = () => true; window.alert = () => {}; }")
|
||||
|
||||
raise TimeoutError(f"입력완료가 반영되지 않았습니다: {debug_label}")
|
||||
except Exception as e:
|
||||
_try_log_pitch(log_info, False, type(e).__name__, str(e), time() - t0)
|
||||
raise e
|
||||
|
||||
|
||||
def handle_late_runner_events(page: Page, event: dict[str, Any], late_events: list[dict[str, Any]], write_events: bool, job_id: str | None = None) -> None:
|
||||
if not late_events or not write_events:
|
||||
return
|
||||
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
from automation.page_helpers import get_last_history_text
|
||||
current_history = get_last_history_text(page)
|
||||
all_matched = True
|
||||
for le in late_events:
|
||||
le_text = le.get("text", "")
|
||||
if le_text and le_text not in current_history:
|
||||
all_matched = False
|
||||
break
|
||||
if all_matched:
|
||||
return
|
||||
|
||||
new_late = set_runner_events(page, event, late_events)
|
||||
|
||||
submit_input_complete(
|
||||
page,
|
||||
f"지연 주루 처리: {', '.join(e.get('text', '') for e in late_events)}",
|
||||
clear_defense=True,
|
||||
log_info={"job_id": job_id} if job_id else None
|
||||
)
|
||||
|
||||
if new_late:
|
||||
handle_late_runner_events(page, event, new_late, write_events, job_id)
|
||||
|
||||
|
||||
def build_runner_event_lines(event: dict[str, Any]) -> list[str]:
|
||||
from core.runner_classifier import infer_runner_action_label
|
||||
lines: list[str] = []
|
||||
for runner_event in (event.get("runnerEvents") or []):
|
||||
r_text = runner_event.get("text", "")
|
||||
from_b = runner_event.get("fromBase", "?")
|
||||
to_b = runner_event.get("toBase", "?")
|
||||
label = infer_runner_action_label(event, runner_event)
|
||||
line = f"🏃 {from_b}루주자 -> {to_b}루 : {r_text}"
|
||||
if label:
|
||||
line += f" | 라벨: {label}"
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def process_only_reviews(page: Page, report: dict[str, Any], write_events: bool, job_id: str = None) -> None:
|
||||
all_reviews = []
|
||||
from automation.review_input import _normalize_review_event
|
||||
for half_inning in report.get("game_contents", []):
|
||||
for event in half_inning.get("events", []):
|
||||
if event.get("event_type") == "at_bat":
|
||||
reviews = event.get("reviewEvents") or []
|
||||
for r in reviews:
|
||||
all_reviews.append(_normalize_review_event(r))
|
||||
|
||||
if not all_reviews:
|
||||
show_debug_overlay(page, ["입력할 합의판정 기록이 없습니다."])
|
||||
page.wait_for_timeout(2000)
|
||||
return
|
||||
|
||||
show_debug_overlay(page, [f"합의판정 {len(all_reviews)}건 일괄 등록 시작"])
|
||||
if write_events:
|
||||
record_review_events(page, all_reviews)
|
||||
show_debug_overlay(page, ["합의판정 일괄 등록 완료"])
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
|
||||
def process_report(page: Page, report: dict[str, Any], write_events: bool, job_id: str = None) -> None:
|
||||
from core.pitch_classifier import normalize_pitch_result_code
|
||||
|
||||
outs = 0
|
||||
change_cache: dict[str, tuple[str, int]] = {}
|
||||
applied_change_texts: set[str] = set()
|
||||
|
||||
for half_inning in report.get("game_contents", []):
|
||||
inning = half_inning.get("inning", "")
|
||||
outs = 0
|
||||
for event in half_inning.get("events", []):
|
||||
if event.get("event_type") == "change":
|
||||
change_text = (event.get("text") or "").strip()
|
||||
show_debug_overlay(page, [f"교체 입력: {change_text or '-'}"])
|
||||
wait_for_operator_control(page)
|
||||
|
||||
if write_events:
|
||||
if change_text and change_text in applied_change_texts:
|
||||
show_debug_overlay(page, ["교체 중복 건너뜀", change_text])
|
||||
page.wait_for_timeout(250)
|
||||
continue
|
||||
|
||||
log_info_event = {
|
||||
"job_id": job_id, "inning": inning, "event_type": event.get("change_type", "change"),
|
||||
"target_player": event.get("in_player") or event.get("to_position", ""),
|
||||
"actual_player": event.get("actor_name") or event.get("player_name", "")
|
||||
} if job_id else None
|
||||
|
||||
try:
|
||||
apply_change_event(page, half_inning, event, change_cache)
|
||||
_try_log_event(log_info_event, True)
|
||||
except Exception as e:
|
||||
_try_log_event(log_info_event, False, str(e))
|
||||
raise e
|
||||
|
||||
if change_text:
|
||||
applied_change_texts.add(change_text)
|
||||
show_debug_overlay(page, ["교체 완료", f"{change_text or '-'}"])
|
||||
page.wait_for_timeout(120)
|
||||
continue
|
||||
|
||||
if event.get("event_type") != "at_bat":
|
||||
continue
|
||||
|
||||
clear_defense_selections(page)
|
||||
balls = 0
|
||||
strikes = 0
|
||||
pitches = event.get("pitches") or []
|
||||
result = event.get("result") or {}
|
||||
|
||||
for pitch_index, pitch in enumerate(pitches):
|
||||
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
|
||||
normalized_pitch_result = normalize_pitch_result_code(pitch, event)
|
||||
is_balk_strike = "보크" in pitch_result_text and ("스트라이크" in pitch_result_text or "헛스윙" in pitch_result_text)
|
||||
|
||||
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
|
||||
pitch_result_text = "피치클락 투수위반 볼"
|
||||
|
||||
show_debug_overlay(
|
||||
page,
|
||||
[
|
||||
f"다음 카운트: {balls}볼 {strikes}스트 {outs}아웃",
|
||||
f"다음 공: {pitch.get('pitchNo')}구 {pitch_result_text}",
|
||||
f"구종/구속: {(pitch.get('pitchType') or '-')} / {(pitch.get('speedKmh') or '-')}",
|
||||
f"타석: {event.get('batter') or '-'}",
|
||||
],
|
||||
)
|
||||
wait_for_operator_control(page)
|
||||
|
||||
is_last_pitch = pitch_index == len(pitches) - 1
|
||||
is_action_result = is_last_pitch and result.get("type") in {
|
||||
"hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple",
|
||||
"home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "bunt_hit",
|
||||
"single_runner_out", "double_runner_out", "triple_runner_out"
|
||||
}
|
||||
is_in_play = (pitch.get("pitchResult") == "H") or is_action_result
|
||||
|
||||
if is_last_pitch and is_in_play:
|
||||
continue
|
||||
|
||||
if is_balk_strike:
|
||||
if write_events:
|
||||
current_late = []
|
||||
p_runner_events = pitch.get("runnerEvents")
|
||||
if p_runner_events:
|
||||
current_late.extend(set_runner_events(page, event, p_runner_events))
|
||||
|
||||
if is_last_pitch and event.get("runnerEvents"):
|
||||
current_late.extend(set_runner_events(page, event))
|
||||
|
||||
submit_input_complete(
|
||||
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크", clear_defense=True,
|
||||
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": "보크", "selected_value": "보크"} if job_id else None
|
||||
)
|
||||
if current_late:
|
||||
handle_late_runner_events(page, event, current_late, True, job_id)
|
||||
|
||||
page.wait_for_timeout(80)
|
||||
|
||||
set_pitch_meta_only(page, pitch)
|
||||
if "헛스윙" in pitch_result_text:
|
||||
set_radio_by_label(page, "evt_batter", "헛스윙(스트라이크)")
|
||||
else:
|
||||
set_radio_by_label(page, "evt_batter", "스트라이크(루킹)")
|
||||
|
||||
if write_events:
|
||||
submit_input_complete(
|
||||
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크 후 {'헛스윙' if '헛스윙' in pitch_result_text else '스트라이크'}", clear_defense=True,
|
||||
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": pitch_result_text, "selected_value": pitch_result_text} if job_id else None
|
||||
)
|
||||
else:
|
||||
set_pitch(page, pitch, event)
|
||||
if write_events:
|
||||
current_late = []
|
||||
p_runner_events = get_pitch_runner_events(pitch, event)
|
||||
is_wild_pitch = any(re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") for re in p_runner_events)
|
||||
is_passed_ball = any(re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") for re in p_runner_events)
|
||||
|
||||
extra_log = " (폭투)" if is_wild_pitch else " (포일)" if is_passed_ball else ""
|
||||
|
||||
if p_runner_events:
|
||||
current_late.extend(set_runner_events(page, event, p_runner_events))
|
||||
|
||||
if is_last_pitch and event.get("runnerEvents"):
|
||||
current_late.extend(set_runner_events(page, event))
|
||||
|
||||
if "파울플라이" in pitch_result_text and "실책" in pitch_result_text:
|
||||
fill_error_defense_popup(page, pitch_result_text)
|
||||
|
||||
submit_input_complete(
|
||||
page, f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 {pitch_result_text or '-'}{extra_log}", clear_defense=True,
|
||||
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": pitch.get("pitchNo", 0), "target_value": f"{pitch_result_text}{extra_log}", "selected_value": "폭투-볼" if is_wild_pitch else "포일-볼" if is_passed_ball else pitch_result_text} if job_id else None
|
||||
)
|
||||
if current_late:
|
||||
handle_late_runner_events(page, event, current_late, True, job_id)
|
||||
|
||||
balls, strikes = advance_count(balls, strikes, normalized_pitch_result)
|
||||
|
||||
if result:
|
||||
last_pitch = pitches[-1] if pitches else {}
|
||||
action_result_types = {
|
||||
"hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple",
|
||||
"home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "bunt_hit",
|
||||
"single_runner_out", "double_runner_out", "triple_runner_out", "play"
|
||||
}
|
||||
|
||||
if last_pitch.get("pitchResult") == "H" or result.get("type") in action_result_types:
|
||||
runner_lines = build_runner_event_lines(event)
|
||||
result_text = result.get('text') or ''
|
||||
def_seq = []
|
||||
|
||||
if is_error_result(result_text):
|
||||
err_pos = extract_error_position(result_text)
|
||||
if err_pos:
|
||||
click_count = 2 if is_throwing_error(result_text) else 1
|
||||
def_seq = [err_pos] * click_count
|
||||
elif result.get("type") in {"out", "double_play", "sacrifice_fly", "sacrifice_bunt", "strikeout", "reach_on_fielder_choice", "reach_on_grounder", "single_runner_out", "double_runner_out", "triple_runner_out", "play"}:
|
||||
def_seq = extract_defense_sequence(result_text)
|
||||
if "직선타" in result_text or "라인드라이브" in result_text or "플라이" in result_text:
|
||||
if def_seq:
|
||||
def_seq = [def_seq[0]]
|
||||
|
||||
defense_lines = [f"⚾ 누를 수비수: {', '.join(def_seq)}"] if def_seq else []
|
||||
|
||||
show_debug_overlay(
|
||||
page,
|
||||
[
|
||||
f"📌 타격 결과: {result_text or '-'}",
|
||||
f"🎯 현재 카운트: {balls}B {strikes}S {outs}O",
|
||||
*defense_lines,
|
||||
*runner_lines,
|
||||
],
|
||||
)
|
||||
wait_for_operator_control(page)
|
||||
set_pitch_meta_only(page, last_pitch)
|
||||
|
||||
if write_events:
|
||||
page.wait_for_timeout(120)
|
||||
simple_terminal_result = is_simple_terminal_result_type(result.get("type") or "")
|
||||
expected_batter_event = infer_batter_result_label(result, event) or ""
|
||||
|
||||
if expected_batter_event:
|
||||
for _ in range(5):
|
||||
set_batter_result_type(page, result, event)
|
||||
page.wait_for_timeout(50)
|
||||
if get_checked_event_name(page, "evt_batter") == expected_batter_event:
|
||||
break
|
||||
else:
|
||||
set_batter_result_type(page, result, event)
|
||||
page.wait_for_timeout(50)
|
||||
|
||||
popup_defense_used = False
|
||||
if not simple_terminal_result:
|
||||
if result.get("type") in {"reach_on_error", "double_play"} or get_checked_batter_defense_type(page):
|
||||
set_hit_ball_and_defense(page, event)
|
||||
popup_defense_used = True
|
||||
set_batter_advancement(page, result)
|
||||
|
||||
current_late = []
|
||||
all_runner_events = (event.get("runnerEvents") or []).copy()
|
||||
if last_pitch.get("runnerEvents"):
|
||||
all_runner_events.extend(last_pitch["runnerEvents"])
|
||||
|
||||
if all_runner_events:
|
||||
current_late.extend(set_runner_events(page, event, all_runner_events))
|
||||
page.wait_for_timeout(30)
|
||||
|
||||
submit_input_complete(
|
||||
page, f"{event.get('batter') or '-'} / {result_text or '-'}", clear_defense=not popup_defense_used,
|
||||
log_info={"job_id": job_id, "inning": inning, "batter": event.get("batter", ""), "pitch_no": (last_pitch.get("pitchNo", 0) if isinstance(last_pitch, dict) else 0), "target_value": result_text, "selected_value": expected_batter_event} if job_id else None
|
||||
)
|
||||
|
||||
if current_late:
|
||||
handle_late_runner_events(page, event, current_late, True, job_id)
|
||||
|
||||
result_type = result.get("type")
|
||||
result_text = (result.get("text") or "").strip()
|
||||
if result_type == "double_play":
|
||||
outs += 2
|
||||
if outs >= 3:
|
||||
break
|
||||
elif "낫아웃" in result_text and not any(token in result_text for token in ("폭투", "포일", "진루", "출루", "세이프")):
|
||||
outs += 1
|
||||
if outs >= 3:
|
||||
break
|
||||
elif result_type in {"out", "strikeout", "sacrifice_fly", "sacrifice_bunt", "single_runner_out", "double_runner_out", "triple_runner_out"}:
|
||||
outs += 1
|
||||
if outs >= 3:
|
||||
break
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace, report: dict[str, Any]) -> None:
|
||||
browser = launch_browser_context(playwright, args.user_data_dir, args.channel, args.headless)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
job_id = getattr(args, "job_id", None) or os.environ.get("JOB_ID")
|
||||
|
||||
if args.write_events and not job_id:
|
||||
try:
|
||||
import db_logging
|
||||
import uuid
|
||||
job_id = f"standalone-{uuid.uuid4().hex[:8]}"
|
||||
db_logging.start_job(job_id, args.game_id, getattr(args, "start_inning", ""), getattr(args, "end_inning", ""))
|
||||
except Exception:
|
||||
job_id = None
|
||||
|
||||
try:
|
||||
open_game_status_page(page, args.base_url, report, args.manager_game_no)
|
||||
if getattr(args, "review_only", False):
|
||||
process_only_reviews(page, report, args.write_events, job_id=job_id)
|
||||
else:
|
||||
process_report(page, report, args.write_events, job_id=job_id)
|
||||
|
||||
if not args.close:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
finally:
|
||||
if args.write_events and job_id:
|
||||
try:
|
||||
import db_logging
|
||||
db_logging.finish_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
if args.close:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
29
config/crawler_constants.yaml
Normal file
29
config/crawler_constants.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# crawler_constants.yaml
|
||||
# 네이버 API 크롤러 상수
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# HTTP 요청 헤더
|
||||
headers:
|
||||
User-Agent: "Mozilla/5.0"
|
||||
Accept: "application/json, text/plain, */*"
|
||||
Accept-Language: "ko-KR,ko;q=0.9"
|
||||
Origin: "https://m.sports.naver.com"
|
||||
x-sports-backend: "kotlin"
|
||||
|
||||
# 무시할 textOption type 코드
|
||||
skip_option_types: [0, 8, 98, 99]
|
||||
|
||||
# 무시할 이벤트 텍스트
|
||||
hidden_event_texts:
|
||||
- "투수 투수판 이탈"
|
||||
- "코칭스태프 마운드 방문"
|
||||
- "포수 마운드 방문"
|
||||
|
||||
# 교체 키워드 (텍스트에 이것이 포함되면 교체 이벤트로 판단)
|
||||
change_keywords:
|
||||
- "(으)로 교체"
|
||||
- "수비위치 변경"
|
||||
|
||||
# 최대 이닝 수
|
||||
max_inning: 20
|
||||
39
config/field_coordinates.yaml
Normal file
39
config/field_coordinates.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# field_coordinates.yaml
|
||||
# 타구 좌표, 타구종류, 파울 좌표
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# 타구 좌표 (zone → x, y 퍼센트)
|
||||
field_coordinates:
|
||||
투수: [50, 80]
|
||||
포수: [50, 93]
|
||||
1루수: [63, 77]
|
||||
2루수: [60, 65]
|
||||
3루수: [37, 77]
|
||||
유격수: [40, 65]
|
||||
좌익수: [22, 42]
|
||||
중견수: [50, 24]
|
||||
우익수: [78, 42]
|
||||
좌전: [30, 50]
|
||||
중전: [50, 35]
|
||||
우전: [70, 50]
|
||||
좌중간: [34, 34]
|
||||
우중간: [66, 34]
|
||||
좌월: [20, 30]
|
||||
중월: [50, 14]
|
||||
우월: [80, 30]
|
||||
|
||||
# 타구 종류 매핑 (라벨 → value)
|
||||
hit_ball_type:
|
||||
땅볼: "0"
|
||||
일반바운드: "1"
|
||||
플라이: "2"
|
||||
라인드라이브: "3"
|
||||
펜스타구: "4"
|
||||
홈런성타구: "5"
|
||||
번트타구: "6"
|
||||
|
||||
# 파울 플라이 기준 좌표
|
||||
foul_fly:
|
||||
left: [2, 70]
|
||||
right: [98, 70]
|
||||
83
config/mappings.yaml
Normal file
83
config/mappings.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# mappings.yaml
|
||||
# 관리자 사이트 허용값(key) → 매핑되는 네이버/입력 표기(aliases)
|
||||
#
|
||||
# 구조: site_label: [alias_1, alias_2, ...]
|
||||
# 매핑 방향: alias → site_label (역매핑으로 조회)
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# 팀명 (관리자 사이트에서 사용하는 팀 표기)
|
||||
team_name:
|
||||
Hero: [키움, 키움 히어로즈, Hero]
|
||||
# 나머지 팀은 네이버 표기와 사이트 표기가 동일하여 별도 매핑 불필요
|
||||
|
||||
# 팀 코드 → 한글 팀명 (네이버 API gameId 파싱용)
|
||||
team_code:
|
||||
한화: [HH]
|
||||
KIA: [HT]
|
||||
KT: [KT]
|
||||
LG: [LG]
|
||||
롯데: [LT]
|
||||
NC: [NC]
|
||||
두산: [OB]
|
||||
SSG: [SK]
|
||||
삼성: [SS]
|
||||
키움: [WO]
|
||||
|
||||
# 구장명 (관리자 사이트 select 옵션 라벨)
|
||||
stadium_name:
|
||||
고척돔: [고척, 고척스카이돔]
|
||||
잠실: [잠실, 잠실야구장]
|
||||
대구라팍: [대구 삼성 라이온즈 파크, 대구라이온즈파크, 대구 라팍, 대구삼성라이온즈파크]
|
||||
수원: [수원 케이티 위즈 파크, 수원KT위즈파크, 수원kt위즈파크]
|
||||
창원: [창원NC파크, 창원 nc 파크, 창원 NC 파크]
|
||||
대전: [대전 한화생명 볼파크, 대전한화생명볼파크]
|
||||
"한밭(~2024)": [대전 한화생명 이글스파크, 대전한화생명이글스파크]
|
||||
문학: [인천, 인천 SSG 랜더스필드, 인천SSG랜더스필드, 문학]
|
||||
광주: [광주-기아 챔피언스 필드, 광주 기아 챔피언스 필드, 광주KIA챔피언스필드, 광주 kia 챔피언스 필드]
|
||||
사직: [사직야구장, 사직]
|
||||
울산: [울산문수야구장, 울산 문수야구장, 울산]
|
||||
포항: [포항야구장, 포항]
|
||||
마산: [마산야구장, 마산]
|
||||
군산: [군산월명야구장, 군산]
|
||||
청주: [청주야구장, 청주]
|
||||
목동: [목동야구장, 목동]
|
||||
무등: [무등야구장, 무등]
|
||||
대구: [대구시민야구장, 대구]
|
||||
|
||||
# 경기 유형 (관리자 사이트 select 옵션)
|
||||
game_type:
|
||||
정규경기: [kbo_r]
|
||||
와일드카드: [wildcard, wc]
|
||||
와일드카드 결정전: [와일드카드]
|
||||
준플레이오프: [semi_playoff, semi_po]
|
||||
플레이오프: [playoff, po]
|
||||
한국시리즈: [korean_series, ks]
|
||||
|
||||
# 포지션 번호 (관리자 사이트 defense_no)
|
||||
position_number:
|
||||
"1": [투수]
|
||||
"2": [포수]
|
||||
"3": [1루수]
|
||||
"4": [2루수]
|
||||
"5": [3루수]
|
||||
"6": [유격수]
|
||||
"7": [좌익수]
|
||||
"8": [중견수]
|
||||
"9": [우익수]
|
||||
"10": [지명타자]
|
||||
|
||||
# KBO 시즌 ID 후보 (경기 타입별)
|
||||
kbo_sr_id_candidates:
|
||||
정규경기: ["0", "1", "2", "3", "4", "5", "7", "8", "9"]
|
||||
와일드카드: ["3", "0", "1", "2", "4", "5", "7", "8", "9"]
|
||||
준플레이오프: ["4", "0", "1", "2", "3", "5", "7", "8", "9"]
|
||||
플레이오프: ["5", "0", "1", "2", "3", "4", "7", "8", "9"]
|
||||
한국시리즈: ["7", "0", "1", "2", "3", "4", "5", "8", "9"]
|
||||
|
||||
# 투수 결과 라벨
|
||||
result_labels:
|
||||
승리투수: [W]
|
||||
패전투수: [L]
|
||||
홀드: [H]
|
||||
세이브: [S]
|
||||
76
config/pitch_rules.yaml
Normal file
76
config/pitch_rules.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# pitch_rules.yaml
|
||||
# 관리자 사이트 허용값(key) → 매핑되는 네이버 표기(aliases)
|
||||
#
|
||||
# 구조: site_label: [naver_alias_1, naver_alias_2, ...]
|
||||
# 매핑 방향: 네이버 alias → site_label (역매핑으로 조회)
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# 구종 (관리자 사이트 evt_ballType 옵션)
|
||||
pitch_type:
|
||||
패스트볼: [직구, 패스트볼]
|
||||
커브: [커브]
|
||||
체인지업: [체인지업]
|
||||
슬라이더: [슬라이더]
|
||||
커터: [커터]
|
||||
스플리터: [스플리터]
|
||||
너클: [너클]
|
||||
폭투: [폭투]
|
||||
투심: [투심]
|
||||
싱커: [싱커]
|
||||
포크볼: [포크, 포크볼]
|
||||
기타: [] # 매핑되지 않는 구종의 폴백
|
||||
|
||||
# 투구 결과 (관리자 사이트 evt_batter 투구결과 영역)
|
||||
pitch_result:
|
||||
볼: [볼]
|
||||
"스트라이크(루킹)": [스트라이크]
|
||||
"헛스윙(스트라이크)": [헛스윙]
|
||||
번트시도-스트라이크: [헛스윙 번트, 번트 헛스윙, 번트헛스윙]
|
||||
파울: [파울]
|
||||
번트-파울: [번트파울]
|
||||
몸에 맞는 볼: [몸에 맞는 볼, 몸에 맞는 공, 사구]
|
||||
고의사구: [고의사구, 자동 고의사구]
|
||||
폭투-볼: [폭투-볼]
|
||||
포일-볼: [포일-볼]
|
||||
보크: [보크]
|
||||
보크-볼: [보크-볼]
|
||||
노카운트: [노카운트]
|
||||
|
||||
# 타자 결과 (관리자 사이트 evt_batter 타자결과 영역)
|
||||
batter_result:
|
||||
# ── 세이프 ──
|
||||
1루타: [single]
|
||||
2루타: [double]
|
||||
3루타: [triple]
|
||||
홈런: [home_run]
|
||||
포볼: [walk]
|
||||
고의사구: [intentional_walk]
|
||||
몸에 맞는 볼: [hit_by_pitch]
|
||||
번트안타: [bunt_hit]
|
||||
수비실책: [reach_on_error]
|
||||
야수선택: [reach_on_fielder_choice]
|
||||
"땅볼출루(무안타)": [reach_on_grounder]
|
||||
"1루타 후 주루아웃": [single_runner_out]
|
||||
"2루타 후 주루아웃": [double_runner_out]
|
||||
"3루타 후 주루아웃": [triple_runner_out]
|
||||
"1루타 후 수비실책진루": [single_error_advance]
|
||||
"2루타 후 수비실책진루": [double_error_advance]
|
||||
"3루타 후 수비실책진루": [triple_error_advance]
|
||||
# ── 아웃 ──
|
||||
"루킹스트라이크-아웃": [strikeout]
|
||||
번트-삼진: [bunt_strikeout]
|
||||
아웃: [out]
|
||||
희생 플라이: [sacrifice_fly]
|
||||
희생 번트: [sacrifice_bunt]
|
||||
|
||||
# 주루 이벤트 (관리자 사이트 evt_runner_N)
|
||||
runner_event:
|
||||
일반 진루: [advance, score]
|
||||
도루성공: [steal]
|
||||
도루시도 아웃: [steal_fail]
|
||||
포스아웃: [force_out]
|
||||
견제 아웃: [pickoff_out]
|
||||
수비 실책: [error_advance]
|
||||
폭투-진루성공: [wild_pitch_advance]
|
||||
포일-진루성공: [passed_ball_advance]
|
||||
32
config/review_rules.yaml
Normal file
32
config/review_rules.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# review_rules.yaml
|
||||
# 합의판정 항목 → 결과 그룹 매핑
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# 합의판정 항목별 결과 그룹 (game_report.py + record_game_playwright.py 통합)
|
||||
# type1 = 페어/파울, type2 = 아웃/세이프, type3 = 인정/불인정
|
||||
review_result_groups:
|
||||
홈런타구 페어 파울:
|
||||
type: type1
|
||||
options: [페어, 파울]
|
||||
외야타구 페어 파울:
|
||||
type: type1
|
||||
options: [페어, 파울]
|
||||
포수/태그플레이 아웃/세이프:
|
||||
type: type2
|
||||
options: [아웃, 세이프]
|
||||
야수의 포구:
|
||||
type: type2
|
||||
options: [아웃, 세이프]
|
||||
몸에 맞는 공:
|
||||
type: type3
|
||||
options: [인정, 불인정]
|
||||
파울:
|
||||
type: type3
|
||||
options: [인정, 불인정]
|
||||
헛스윙:
|
||||
type: type3
|
||||
options: [인정, 불인정]
|
||||
기타:
|
||||
type: type3
|
||||
options: [인정, 불인정]
|
||||
29
config/site_selectors.yaml
Normal file
29
config/site_selectors.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# site_selectors.yaml
|
||||
# 관리자 사이트 CSS 셀렉터 및 수비 버튼 매핑
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# 수비 버튼 CSS 셀렉터 (포지션 → selector)
|
||||
defense_button_id:
|
||||
투수: "input[name='defenseNumberBtn']#picher"
|
||||
포수: "input[name='defenseNumberBtn']#catcher"
|
||||
1루수: "input[name='defenseNumberBtn']#runner_1"
|
||||
2루수: "input[name='defenseNumberBtn']#runner_2"
|
||||
3루수: "input[name='defenseNumberBtn']#runner_3"
|
||||
유격수: "input[name='defenseNumberBtn']#shortStop"
|
||||
중견수: "input[name='defenseNumberBtn']#centerFielder"
|
||||
우익수: "input[name='defenseNumberBtn']#rightFielder"
|
||||
좌익수: "input[name='defenseNumberBtn']#leftFielder"
|
||||
|
||||
# 포지션 → 수비번호 (사이트 defense_no select 옵션 value)
|
||||
position_to_defense_no:
|
||||
투수: "1"
|
||||
포수: "2"
|
||||
1루수: "3"
|
||||
2루수: "4"
|
||||
3루수: "5"
|
||||
유격수: "6"
|
||||
좌익수: "7"
|
||||
중견수: "8"
|
||||
우익수: "9"
|
||||
지명타자: "10"
|
||||
6
core/__init__.py
Normal file
6
core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
core/ — 순수 비즈니스 로직 패키지
|
||||
|
||||
Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다.
|
||||
모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다.
|
||||
"""
|
||||
85
core/change_parser.py
Normal file
85
core/change_parser.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
core/change_parser.py — 선수 교체 이벤트 파싱
|
||||
|
||||
교체 텍스트에서 선수명, 포지션, 교체 유형 등을 추출합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import position_to_defense_no
|
||||
|
||||
|
||||
def extract_change_actor(text: str) -> tuple[str | None, int | None, str]:
|
||||
"""교체 텍스트의 왼쪽(actor)에서 역할, 타순, 이름 추출
|
||||
|
||||
'5번타자 문보경' → ('batter', 5, '문보경')
|
||||
'투수 임찬규' → ('투수', None, '임찬규')
|
||||
"""
|
||||
lhs = (text or "").split(" : ", 1)[0].strip()
|
||||
|
||||
batter_match = re.search(r"(\d+)번타자\s+(.+)$", lhs)
|
||||
if batter_match:
|
||||
return "batter", int(batter_match.group(1)), batter_match.group(2).strip()
|
||||
|
||||
roles = (
|
||||
"대타", "대주자",
|
||||
"1루주자", "2루주자", "3루주자", "주자",
|
||||
"투수", "포수", "1루수", "2루수", "3루수",
|
||||
"유격수", "좌익수", "중견수", "우익수",
|
||||
)
|
||||
for role in roles:
|
||||
if lhs.startswith(role + " "):
|
||||
return role, None, lhs[len(role):].strip()
|
||||
|
||||
return None, None, lhs
|
||||
|
||||
|
||||
def is_merged_pitcher_substitution(actor_role: str | None, in_role: str | None) -> bool:
|
||||
"""야수→투수 교체인지 확인 (투수가 DH로 전환되는 병합 교체)"""
|
||||
field_roles = {"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"}
|
||||
return actor_role in field_roles and in_role == "투수"
|
||||
|
||||
|
||||
def normalize_change_event(change_event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""교체 이벤트를 정규화
|
||||
|
||||
텍스트 파싱 → actor_name, out_player, in_player, change_type 등 추출
|
||||
"""
|
||||
if change_event.get("actor_name") or change_event.get("player_name"):
|
||||
return change_event
|
||||
|
||||
text = change_event.get("text") or ""
|
||||
normalized = dict(change_event)
|
||||
normalized["change_type"] = "position_change" if "수비위치 변경" in text else "substitution"
|
||||
actor_role, bat_order, actor_name = extract_change_actor(text)
|
||||
normalized["actor_role"] = actor_role
|
||||
normalized["actor_name"] = actor_name
|
||||
if bat_order is not None:
|
||||
normalized["bat_order"] = bat_order
|
||||
|
||||
if normalized["change_type"] == "position_change":
|
||||
rhs = text.split(" : ", 1)[1] if " : " in text else ""
|
||||
normalized["player_name"] = actor_name
|
||||
normalized["to_position"] = rhs.split("(으)로", 1)[0].strip()
|
||||
return normalized
|
||||
|
||||
rhs = text.split(" : ", 1)[1] if " : " in text else ""
|
||||
rhs = rhs.split("(으)로 교체", 1)[0].strip()
|
||||
in_role, _, in_name = extract_change_actor(rhs)
|
||||
normalized["out_player"] = actor_name
|
||||
normalized["in_player"] = in_name
|
||||
normalized["in_role"] = in_role
|
||||
|
||||
pos_defense = position_to_defense_no()
|
||||
if is_merged_pitcher_substitution(actor_role, in_role):
|
||||
normalized["change_type"] = "merged_pitcher_substitution"
|
||||
normalized["player_name"] = actor_name
|
||||
normalized["to_position"] = "지명타자"
|
||||
normalized["pitcher_in_player"] = in_name
|
||||
return normalized
|
||||
|
||||
if in_role in pos_defense:
|
||||
normalized["to_position"] = in_role
|
||||
return normalized
|
||||
195
core/config_loader.py
Normal file
195
core/config_loader.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
config_loader.py — YAML 설정 파일 로딩 + 캐싱
|
||||
|
||||
모든 설정 접근의 단일 진입점.
|
||||
config/ 폴더의 YAML 파일을 로드하고 lru_cache로 캐싱합니다.
|
||||
|
||||
YAML 구조: site_label(key) → [alias_1, alias_2, ...] (Closed Set 기반)
|
||||
조회 시: alias → site_label (역매핑)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def load_config(name: str) -> dict[str, Any]:
|
||||
"""YAML 파일을 로드하여 dict로 반환 (결과 캐싱)"""
|
||||
path = CONFIG_DIR / f"{name}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {path}")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data
|
||||
|
||||
|
||||
def get_mapping(config_name: str, key: str) -> dict[str, Any]:
|
||||
"""특정 설정 파일의 특정 섹션을 반환 (원본 구조 그대로)"""
|
||||
return load_config(config_name).get(key, {})
|
||||
|
||||
|
||||
def get_list(config_name: str, key: str) -> list:
|
||||
"""특정 설정 파일의 특정 리스트 섹션을 반환"""
|
||||
return load_config(config_name).get(key, [])
|
||||
|
||||
|
||||
def get_value(config_name: str, key: str, default: Any = None) -> Any:
|
||||
"""특정 설정 파일의 단일 값을 반환"""
|
||||
return load_config(config_name).get(key, default)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Closed Set 역매핑 빌드
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _build_reverse_map(config_name: str, key: str) -> dict[str, str]:
|
||||
"""site_label: [aliases...] 구조를 {alias: site_label} 역매핑으로 변환
|
||||
|
||||
예: { '패스트볼': ['직구', '패스트볼'] }
|
||||
→ { '직구': '패스트볼', '패스트볼': '패스트볼' }
|
||||
"""
|
||||
raw = get_mapping(config_name, key)
|
||||
reverse: dict[str, str] = {}
|
||||
for site_label, aliases in raw.items():
|
||||
if isinstance(aliases, list):
|
||||
for alias in aliases:
|
||||
reverse[str(alias)] = str(site_label)
|
||||
else:
|
||||
# aliases가 리스트가 아닌 경우 (단순 값이면 그대로)
|
||||
reverse[str(aliases)] = str(site_label)
|
||||
return reverse
|
||||
|
||||
|
||||
def allowed_values(config_name: str, key: str) -> set[str]:
|
||||
"""해당 섹션의 관리자 사이트 허용값(Closed Set) 반환"""
|
||||
raw = get_mapping(config_name, key)
|
||||
return set(raw.keys())
|
||||
|
||||
|
||||
def lookup(config_name: str, key: str, alias: str) -> str | None:
|
||||
"""alias → site_label 조회. 없으면 None"""
|
||||
return _build_reverse_map(config_name, key).get(alias)
|
||||
|
||||
|
||||
def lookup_or_raise(config_name: str, key: str, alias: str) -> str:
|
||||
"""alias → site_label 조회. 없으면 오류"""
|
||||
result = lookup(config_name, key, alias)
|
||||
if result is None:
|
||||
allowed = allowed_values(config_name, key)
|
||||
raise ValueError(
|
||||
f"매핑 오류: '{alias}'는 {key}의 허용값에 없습니다. "
|
||||
f"허용값: {sorted(allowed)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 편의 함수: 역매핑 (alias → site_label)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def pitch_type_map() -> dict[str, str]:
|
||||
"""네이버 stuff → 사이트 구종 라벨"""
|
||||
return _build_reverse_map("pitch_rules", "pitch_type")
|
||||
|
||||
def pitch_result_map() -> dict[str, str]:
|
||||
"""네이버 pitchResultText → 사이트 투구결과 라벨"""
|
||||
return _build_reverse_map("pitch_rules", "pitch_result")
|
||||
|
||||
def batter_result_map() -> dict[str, str]:
|
||||
"""result.type → 사이트 타자결과 라벨"""
|
||||
return _build_reverse_map("pitch_rules", "batter_result")
|
||||
|
||||
def runner_event_map() -> dict[str, str]:
|
||||
"""runnerEvent.type → 사이트 주루 라벨"""
|
||||
return _build_reverse_map("pitch_rules", "runner_event")
|
||||
|
||||
|
||||
def team_name_map() -> dict[str, str]:
|
||||
"""네이버 팀명 → 사이트 팀명"""
|
||||
return _build_reverse_map("mappings", "team_name")
|
||||
|
||||
def team_code_map() -> dict[str, str]:
|
||||
"""네이버 팀코드 → 한글 팀명"""
|
||||
return _build_reverse_map("mappings", "team_code")
|
||||
|
||||
def stadium_name_map() -> dict[str, str]:
|
||||
"""네이버 구장명 → 사이트 구장명"""
|
||||
return _build_reverse_map("mappings", "stadium_name")
|
||||
|
||||
def game_type_map() -> dict[str, str]:
|
||||
"""네이버 경기유형 → 사이트 경기유형"""
|
||||
return _build_reverse_map("mappings", "game_type")
|
||||
|
||||
def position_number_map() -> dict[str, str]:
|
||||
"""포지션명 → 번호"""
|
||||
return _build_reverse_map("mappings", "position_number")
|
||||
|
||||
def result_labels() -> dict[str, str]:
|
||||
"""W/L/H/S → 승리투수/패전투수/홀드/세이브"""
|
||||
return _build_reverse_map("mappings", "result_labels")
|
||||
|
||||
def kbo_sr_id_candidates() -> dict[str, list]:
|
||||
"""역매핑 불필요 — 원본 그대로"""
|
||||
return get_mapping("mappings", "kbo_sr_id_candidates")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 편의 함수: Closed Set 직접 조회
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def pitch_type_allowed() -> set[str]:
|
||||
return allowed_values("pitch_rules", "pitch_type")
|
||||
|
||||
def pitch_result_allowed() -> set[str]:
|
||||
return allowed_values("pitch_rules", "pitch_result")
|
||||
|
||||
def batter_result_allowed() -> set[str]:
|
||||
return allowed_values("pitch_rules", "batter_result")
|
||||
|
||||
def runner_event_allowed() -> set[str]:
|
||||
return allowed_values("pitch_rules", "runner_event")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 편의 함수: 역매핑 불필요한 것들 (원본 구조 그대로)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def field_coordinates() -> dict[str, list]:
|
||||
return get_mapping("field_coordinates", "field_coordinates")
|
||||
|
||||
def hit_ball_type_map() -> dict[str, str]:
|
||||
return get_mapping("field_coordinates", "hit_ball_type")
|
||||
|
||||
def foul_fly_coords() -> dict[str, list]:
|
||||
return get_mapping("field_coordinates", "foul_fly")
|
||||
|
||||
def defense_button_id_map() -> dict[str, str]:
|
||||
return get_mapping("site_selectors", "defense_button_id")
|
||||
|
||||
def position_to_defense_no() -> dict[str, str]:
|
||||
return get_mapping("site_selectors", "position_to_defense_no")
|
||||
|
||||
def review_result_groups() -> dict[str, dict]:
|
||||
return get_mapping("review_rules", "review_result_groups")
|
||||
|
||||
def crawler_headers() -> dict[str, str]:
|
||||
return get_mapping("crawler_constants", "headers")
|
||||
|
||||
def skip_option_types() -> set[int]:
|
||||
return set(get_list("crawler_constants", "skip_option_types"))
|
||||
|
||||
def hidden_event_texts() -> set[str]:
|
||||
return set(get_list("crawler_constants", "hidden_event_texts"))
|
||||
|
||||
def change_keywords() -> tuple[str, ...]:
|
||||
return tuple(get_list("crawler_constants", "change_keywords"))
|
||||
|
||||
def max_inning() -> int:
|
||||
return get_value("crawler_constants", "max_inning", 20)
|
||||
255
core/field_calculator.py
Normal file
255
core/field_calculator.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산
|
||||
|
||||
필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import (
|
||||
field_coordinates,
|
||||
hit_ball_type_map,
|
||||
foul_fly_coords,
|
||||
position_number_map,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 타구 종류 추론
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def infer_hit_ball_type(result_text: str) -> str:
|
||||
"""결과 텍스트에서 타구 종류 추론
|
||||
|
||||
'2루수 땅볼 아웃' → '땅볼'
|
||||
'좌익수 뒤 2루타' → '일반바운드'
|
||||
"""
|
||||
if "번트" in result_text:
|
||||
return "번트타구"
|
||||
if "몸에 맞는 타구" in result_text:
|
||||
return "땅볼"
|
||||
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
|
||||
return "플라이"
|
||||
if "파울플라이" in result_text:
|
||||
return "플라이"
|
||||
if "라인드라이브" in result_text or "직선타" in result_text:
|
||||
return "라인드라이브"
|
||||
if "플라이" in result_text:
|
||||
return "플라이"
|
||||
if "땅볼" in result_text:
|
||||
return "땅볼"
|
||||
if "홈런" in result_text:
|
||||
return "홈런성타구"
|
||||
return "일반바운드"
|
||||
|
||||
|
||||
def get_hit_ball_type_code(hit_ball_type: str) -> str:
|
||||
"""타구 종류 라벨 → 사이트 value 코드"""
|
||||
return hit_ball_type_map().get(hit_ball_type, "1")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 필드 존 추론
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
ORDERED_ZONES = (
|
||||
"좌중간", "우중간",
|
||||
"좌전", "중전", "우전",
|
||||
"좌월", "중월", "우월",
|
||||
"좌익수", "중견수", "우익수",
|
||||
"유격수", "3루수", "2루수", "1루수",
|
||||
"투수", "포수",
|
||||
)
|
||||
|
||||
|
||||
def infer_field_zone(result_text: str) -> str:
|
||||
"""결과 텍스트에서 타구 방향(zone) 추론
|
||||
|
||||
'우익수 앞 1루타' → '우익수'
|
||||
"""
|
||||
if "몸에 맞는 타구" in result_text:
|
||||
return "1루수"
|
||||
for zone in ORDERED_ZONES:
|
||||
if zone in result_text:
|
||||
return zone
|
||||
return "중견수"
|
||||
|
||||
|
||||
def extract_direction_offsets(result_text: str) -> tuple[int, int]:
|
||||
"""결과 텍스트에서 방향 오프셋 추출
|
||||
|
||||
'좌익수 왼쪽 뒤' → (-1, -1)
|
||||
"""
|
||||
x_delta = 0
|
||||
y_delta = 0
|
||||
if "왼쪽" in result_text:
|
||||
x_delta -= 1
|
||||
if "오른쪽" in result_text:
|
||||
x_delta += 1
|
||||
if "앞" in result_text:
|
||||
y_delta += 1
|
||||
if "뒤" in result_text:
|
||||
y_delta -= 1
|
||||
return x_delta, y_delta
|
||||
|
||||
|
||||
def is_infield_zone(zone: str) -> bool:
|
||||
"""내야 존인지 확인"""
|
||||
return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 좌표 계산
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_zone_coordinates(zone: str) -> tuple[int, int]:
|
||||
"""존 이름 → (x, y) 퍼센트 좌표"""
|
||||
coords = field_coordinates()
|
||||
coord = coords.get(zone, coords.get("중견수", [50, 24]))
|
||||
return tuple(coord)
|
||||
|
||||
|
||||
def get_foul_fly_coordinates(side: str) -> tuple[int, int]:
|
||||
"""파울 플라이 좌표 ('left' 또는 'right')"""
|
||||
coords = foul_fly_coords()
|
||||
return tuple(coords.get(side, [50, 70]))
|
||||
|
||||
|
||||
def calculate_hit_ball_coordinates(
|
||||
result_text: str,
|
||||
zone: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""결과 텍스트로부터 타구 좌표 계산
|
||||
|
||||
Returns: (x, y) 퍼센트 좌표
|
||||
"""
|
||||
if zone is None:
|
||||
zone = infer_field_zone(result_text)
|
||||
|
||||
x, y = get_zone_coordinates(zone)
|
||||
x_delta, y_delta = extract_direction_offsets(result_text)
|
||||
|
||||
infield = is_infield_zone(zone)
|
||||
step = 3 if infield else 5
|
||||
|
||||
x += x_delta * step
|
||||
y += y_delta * step
|
||||
|
||||
# 범위 제한
|
||||
x = max(0, min(100, x))
|
||||
y = max(0, min(100, y))
|
||||
|
||||
return x, y
|
||||
|
||||
|
||||
def calculate_distance(x: int, y: int, meter_per_px: float) -> float:
|
||||
"""좌표에서 홈까지의 거리 계산 (미터)"""
|
||||
home_x, home_y = 50, 93
|
||||
dx = (x - home_x) * meter_per_px
|
||||
dy = (y - home_y) * meter_per_px
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 수비 시퀀스 추출
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _position_label_map() -> dict[str, str]:
|
||||
"""번호 → 포지션명 역매핑"""
|
||||
return {v: k for k, v in position_number_map().items()}
|
||||
|
||||
|
||||
def extract_defense_sequence(result_text: str) -> list[str]:
|
||||
"""결과 텍스트에서 수비 시퀀스 추출
|
||||
|
||||
'2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수']
|
||||
"""
|
||||
pos_label = _position_label_map()
|
||||
|
||||
# 1) '2-6', '2-5-3' 같은 숫자 패턴
|
||||
num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text)
|
||||
if num_seq_match:
|
||||
nums = num_seq_match.group(1).split("-")
|
||||
pos_names = [pos_label[n] for n in nums if n in pos_label]
|
||||
if pos_names:
|
||||
return pos_names
|
||||
|
||||
# 2) 괄호 안에서 포지션 추출
|
||||
parenthetical_match = re.search(r"\(([^)]*)\)", result_text)
|
||||
if parenthetical_match:
|
||||
sequence = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
parenthetical_match.group(1),
|
||||
)
|
||||
if sequence:
|
||||
return sequence
|
||||
|
||||
# 3) 괄호 앞 본문에서 포지션 추출
|
||||
leading_text = result_text.split("(", 1)[0]
|
||||
sequence = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
leading_text,
|
||||
)
|
||||
if sequence:
|
||||
return sequence
|
||||
|
||||
# 4) 존에서 폴백
|
||||
zone = infer_field_zone(result_text)
|
||||
pos_num = position_number_map()
|
||||
if zone in pos_num:
|
||||
return [zone]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def extract_error_position(result_text: str) -> str | None:
|
||||
"""실책 관련 텍스트에서 실책 수비자 포지션 추출"""
|
||||
parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text)
|
||||
search_texts = [parenthetical_match.group(1)] if parenthetical_match else []
|
||||
search_texts.append(result_text)
|
||||
for text in search_texts:
|
||||
positions = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
text,
|
||||
)
|
||||
if positions:
|
||||
return positions[0]
|
||||
return None
|
||||
|
||||
|
||||
def infer_error_position_fallback(text: str) -> str:
|
||||
"""실책 포지션 추론 폴백"""
|
||||
if "야수선택" in text:
|
||||
return "야수선택"
|
||||
if "도루" in text:
|
||||
return "포수"
|
||||
if "포구" in text:
|
||||
return "포수"
|
||||
if "송구" in text:
|
||||
return "투수"
|
||||
return "포수"
|
||||
|
||||
|
||||
def is_error_result(result_text: str) -> bool:
|
||||
"""실책 결과인지 확인"""
|
||||
return "실책" in result_text
|
||||
|
||||
|
||||
def is_throwing_error(result_text: str) -> bool:
|
||||
"""송구 실책인지 확인"""
|
||||
keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러")
|
||||
return any(keyword in result_text for keyword in keywords)
|
||||
|
||||
|
||||
def is_double_play_result(result_text: str) -> bool:
|
||||
"""병살인지 확인"""
|
||||
return "병살" in result_text
|
||||
|
||||
|
||||
def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]:
|
||||
"""병살 이벤트의 첫 번째 수비 시퀀스"""
|
||||
result_text = ((event.get("result") or {}).get("text") or "").strip()
|
||||
return extract_defense_sequence(result_text)
|
||||
150
core/normalizer.py
Normal file
150
core/normalizer.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
core/normalizer.py — 모든 정규화 함수의 단일 진입점
|
||||
|
||||
팀명, 구장, 포지션, 선수명, 경기유형 등의 정규화를 담당합니다.
|
||||
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import (
|
||||
team_name_map,
|
||||
team_code_map,
|
||||
stadium_name_map,
|
||||
game_type_map,
|
||||
position_number_map,
|
||||
position_to_defense_no,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 팀/구장/경기유형 정규화
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def normalize_team_name(name: str) -> str:
|
||||
"""팀명 정규화 (네이버 표기 → 관리자 사이트 표기)"""
|
||||
return team_name_map().get(name, name)
|
||||
|
||||
|
||||
def normalize_team_code(code: str) -> str:
|
||||
"""팀 코드 → 한글 팀명"""
|
||||
return team_code_map().get(code, code)
|
||||
|
||||
|
||||
def normalize_game_type(name: str) -> str:
|
||||
"""경기 유형 정규화"""
|
||||
return game_type_map().get(name, name)
|
||||
|
||||
|
||||
def normalize_stadium_name(name: str) -> str:
|
||||
"""구장명 정규화 (네이버 표기 → 관리자 사이트 select 라벨)"""
|
||||
return stadium_name_map().get(name, name)
|
||||
|
||||
|
||||
def normalize_position_to_number(position: str) -> str:
|
||||
"""포지션명 → 번호 문자열 (투수→1, 포수→2, ...)"""
|
||||
return position_number_map().get(position, "")
|
||||
|
||||
|
||||
def normalize_position_to_defense_no(position: str) -> str:
|
||||
"""포지션명 → 수비번호 (라인업 select 옵션 value)"""
|
||||
return position_to_defense_no().get(position, "")
|
||||
|
||||
|
||||
def position_label_from_number(number: str) -> str:
|
||||
"""수비번호 → 포지션명 (역매핑)"""
|
||||
pos_map = position_number_map()
|
||||
reverse = {v: k for k, v in pos_map.items()}
|
||||
return reverse.get(number, "")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 선수명/번호 정규화
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def normalize_player_name(name: str | None) -> str:
|
||||
"""선수명 정규화: *, 괄호 내용 제거"""
|
||||
text = (name or "").replace("*", "").strip()
|
||||
text = re.sub(r"\([^)]*\)\s*$", "", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def normalize_lineup_text(text: str) -> str:
|
||||
"""라인업 텍스트에서 순수 이름만 추출
|
||||
|
||||
'[10] 문보경' / '문보경 [10번]' 등 → '문보경'
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
text = text.replace("*", "")
|
||||
text = re.sub(r"\[\d+(?:번)?\]", "", text)
|
||||
text = re.sub(r"\s*\(.*?\)\s*", "", text)
|
||||
text = "".join(re.findall(r"[가-힣A-Za-z]+", text))
|
||||
return text.strip()
|
||||
|
||||
|
||||
def normalize_number_text(number: str | int | None) -> str:
|
||||
"""등번호 정규화: 숫자만 추출"""
|
||||
text = str(number or "").strip()
|
||||
digits = "".join(char for char in text if char.isdigit())
|
||||
if not digits:
|
||||
return ""
|
||||
return str(int(digits))
|
||||
|
||||
|
||||
def normalize_option_player_text(text: str) -> tuple[str, str]:
|
||||
"""select option 텍스트에서 선수명과 번호 분리
|
||||
|
||||
'문보경 [10번]' → ('문보경', '10')
|
||||
"""
|
||||
stripped = " ".join(text.split())
|
||||
matched = re.match(r"^(.*?)\s*\[(\d+)번\]$", stripped)
|
||||
if matched:
|
||||
return normalize_player_name(matched.group(1)), normalize_number_text(matched.group(2))
|
||||
return normalize_player_name(stripped), ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 시간 유틸
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def split_time(iso_time: str | None) -> tuple[str, str]:
|
||||
"""ISO 시간 문자열에서 시/분 분리
|
||||
|
||||
'2026-04-14T18:30:00' → ('18', '30')
|
||||
"""
|
||||
if not iso_time:
|
||||
return "00", "00"
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(iso_time)
|
||||
return f"{dt.hour:02d}", f"{dt.minute:02d}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 텍스트 추론 유틸
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def infer_option_role_hint(text: str) -> str:
|
||||
"""select option 텍스트에서 역할 힌트 추출
|
||||
|
||||
'문보경 (투) [10번]' → 'pitcher'
|
||||
'문보경 (타)' → 'batter'
|
||||
"""
|
||||
stripped = " ".join(text.split())
|
||||
matched = re.search(r"\(([^)]*)\)\s*(?:\[\d+번\])?$", stripped)
|
||||
if not matched:
|
||||
return ""
|
||||
hint = matched.group(1).strip()
|
||||
if hint == "투":
|
||||
return "pitcher"
|
||||
if hint == "타":
|
||||
return "batter"
|
||||
return ""
|
||||
|
||||
|
||||
def infer_target_role_hint(position_name: str | None) -> str:
|
||||
"""포지션명에서 역할 힌트 추론"""
|
||||
if position_name == "투수":
|
||||
return "pitcher"
|
||||
return "batter"
|
||||
273
core/pitch_classifier.py
Normal file
273
core/pitch_classifier.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
core/pitch_classifier.py — 투구/타자 결과 분류
|
||||
|
||||
네이버 리포트 데이터를 기반으로 관리자 사이트에서 선택해야 할
|
||||
라디오 버튼의 라벨(eventName)을 결정합니다.
|
||||
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import (
|
||||
pitch_type_map,
|
||||
pitch_result_map,
|
||||
batter_result_map,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 구종 분류
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def classify_pitch_type(pitch_type_text: str) -> str | None:
|
||||
"""네이버 구종 텍스트 → 사이트 구종 라벨
|
||||
|
||||
예: '직구' → '패스트볼', '포크' → '포크볼'
|
||||
"""
|
||||
return pitch_type_map().get(pitch_type_text or "")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 투구 결과 분류
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def classify_pitch_result(pitch_result_text: str) -> str | None:
|
||||
"""네이버 투구결과 텍스트 → 사이트 투구결과 라벨
|
||||
|
||||
예: '볼' → '볼', '스트라이크' → '스트라이크(루킹)'
|
||||
"""
|
||||
return pitch_result_map().get(pitch_result_text or "")
|
||||
|
||||
|
||||
def normalize_pitch_result_code(pitch: dict[str, Any], event: dict[str, Any] | None = None) -> str:
|
||||
"""투구의 pitchResult 코드를 정규화
|
||||
|
||||
피치클락, 번트헛스윙, 폭투/포일 등 특수 상황 처리
|
||||
"""
|
||||
pitch_result = (pitch.get("pitchResult") or "").strip()
|
||||
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
|
||||
normalized_text = pitch_result_text.replace(" ", "")
|
||||
|
||||
# 피치클락 투수위반 → 볼
|
||||
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
|
||||
return "B"
|
||||
|
||||
# 번트 헛스윙 → BS
|
||||
if "번트" in normalized_text and "헛스윙" in normalized_text:
|
||||
return "BS"
|
||||
|
||||
# 폭투/포일 진루 시 → 볼
|
||||
runner_events = _get_pitch_runner_events(pitch, event)
|
||||
if any(
|
||||
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
):
|
||||
return "B"
|
||||
if any(
|
||||
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
):
|
||||
return "B"
|
||||
|
||||
return pitch_result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 타자 결과 분류
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def classify_batter_result(result_type: str) -> str | None:
|
||||
"""결과 타입 코드 → 사이트 타자결과 라벨 (기본 매핑)
|
||||
|
||||
더 복잡한 추론이 필요한 경우 infer_batter_result_label 사용.
|
||||
"""
|
||||
return batter_result_map().get(result_type or "")
|
||||
|
||||
|
||||
def infer_batter_result_label(
|
||||
result: dict[str, Any],
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""타석 결과를 종합적으로 추론하여 사이트 라벨 반환
|
||||
|
||||
result.type, result.text, 주루이벤트, 마지막 투구 등을 모두 분석.
|
||||
"""
|
||||
result_type = result.get("type") or ""
|
||||
result_text = (result.get("text") or "").strip()
|
||||
runner_events = (event or {}).get("runnerEvents") or []
|
||||
last_pitch_result_text = get_last_pitch_result_text(event)
|
||||
|
||||
# 낫아웃
|
||||
if result_type == "strikeout_not_out" or "낫아웃" in result_text:
|
||||
if "폭투" in result_text:
|
||||
return "폭투 낫아웃 진루"
|
||||
if "포일" in result_text:
|
||||
return "포일 낫아웃 진루"
|
||||
if "아웃" in result_text:
|
||||
return "스트라이크-낫아웃"
|
||||
return "낫아웃-출루"
|
||||
|
||||
# 삼진
|
||||
if result_type == "strikeout":
|
||||
if "헛스윙" in last_pitch_result_text or "헛스윙" in result_text:
|
||||
return "스윙 스트라이크-아웃"
|
||||
return "루킹스트라이크-아웃"
|
||||
|
||||
# 희생 번트
|
||||
if "희생 번트" in result_text or "희생번트" in result_text:
|
||||
return "희생 번트"
|
||||
|
||||
# 번트 아웃
|
||||
if "번트 아웃" in result_text or "번트아웃" in result_text:
|
||||
return "번트-아웃"
|
||||
|
||||
# 보크
|
||||
if any(
|
||||
"보크" in (re_.get("text") or "") and "진루" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
):
|
||||
if "볼" in last_pitch_result_text:
|
||||
return "보크-볼"
|
||||
return "보크"
|
||||
|
||||
# 폭투-볼
|
||||
if any(re_.get("type") == "wild_pitch_advance" for re_ in runner_events):
|
||||
return "폭투-볼"
|
||||
|
||||
# 포볼
|
||||
if result_type == "walk":
|
||||
if any(
|
||||
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
):
|
||||
return "폭투-포볼"
|
||||
if any(
|
||||
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
|
||||
for re_ in runner_events
|
||||
):
|
||||
return "포일-포볼"
|
||||
return "포볼"
|
||||
|
||||
# 포일-볼/스트라이크
|
||||
if any(
|
||||
(re_.get("type") or "") == "passed_ball_advance"
|
||||
for re_ in runner_events
|
||||
):
|
||||
if "볼" in last_pitch_result_text:
|
||||
return "포일-볼"
|
||||
return "포일-스트라이크"
|
||||
|
||||
# 수비실책
|
||||
if result_type == "reach_on_error" or "실책" in result_text:
|
||||
return "수비실책"
|
||||
|
||||
# 야수선택
|
||||
if result_type == "reach_on_fielder_choice":
|
||||
return "야수선택"
|
||||
|
||||
# 땅볼출루
|
||||
if result_type == "reach_on_grounder":
|
||||
return "땅볼출루(무안타)"
|
||||
|
||||
# 병살
|
||||
if result_type == "double_play":
|
||||
if "번트" in result_text:
|
||||
return "번트-병살"
|
||||
return "병살-아웃"
|
||||
|
||||
# N루타 후 주루아웃
|
||||
if result_type == "single_runner_out":
|
||||
return "1루타 후 주루아웃"
|
||||
if result_type == "double_runner_out":
|
||||
return "2루타 후 주루아웃"
|
||||
if result_type == "triple_runner_out":
|
||||
return "3루타 후 주루아웃"
|
||||
|
||||
# N루타 후 수비실책진루
|
||||
if result_type == "single_error_advance":
|
||||
return "1루타 후 수비실책진루"
|
||||
if result_type == "double_error_advance":
|
||||
return "2루타 후 수비실책진루"
|
||||
if result_type == "triple_error_advance":
|
||||
return "3루타 후 수비실책진루"
|
||||
|
||||
# 파울희생플라이
|
||||
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
|
||||
return "희생 플라이"
|
||||
|
||||
# 아웃 상세
|
||||
if result_type == "out":
|
||||
if "병살" in result_text:
|
||||
if "번트" in result_text:
|
||||
return "번트-병살"
|
||||
return "병살-아웃"
|
||||
if "희생 플라이" in result_text or "희생플라이" in result_text:
|
||||
return "희생 플라이"
|
||||
if "인필드플라이" in result_text:
|
||||
return "인필드플라이"
|
||||
if "파울플라이" in result_text:
|
||||
return "파울플라이-아웃"
|
||||
return "아웃"
|
||||
|
||||
# 번트안타
|
||||
if result_type == "bunt_hit":
|
||||
return "번트안타"
|
||||
|
||||
# 내야안타
|
||||
if result_type == "single":
|
||||
if "번트안타" in result_text:
|
||||
return "번트안타"
|
||||
if "내야안타" in result_text:
|
||||
return "내야안타"
|
||||
|
||||
# 몸에 맞는 볼
|
||||
if result_type == "hit_by_pitch" or "헤드샷" in result_text:
|
||||
return "몸에 맞는 볼"
|
||||
|
||||
# 기본 매핑 폴백
|
||||
return classify_batter_result(result_type)
|
||||
|
||||
|
||||
def is_simple_terminal_result_type(result_type: str) -> bool:
|
||||
"""팝업 없이 즉시 완료되는 결과 타입인지 확인"""
|
||||
return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"}
|
||||
|
||||
|
||||
def is_ball_in_play_event(event: dict[str, Any]) -> bool:
|
||||
"""인플레이 이벤트인지 확인 (마지막 투구가 H)"""
|
||||
pitches = event.get("pitches") or []
|
||||
result = event.get("result") or {}
|
||||
if not pitches or not result:
|
||||
return False
|
||||
return pitches[-1].get("pitchResult") == "H"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 내부 헬퍼
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _get_pitch_runner_events(
|
||||
pitch: dict[str, Any],
|
||||
event: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""투구에 연결된 주루이벤트 반환"""
|
||||
if pitch.get("runnerEvents"):
|
||||
return pitch["runnerEvents"]
|
||||
if event:
|
||||
pitch_num = pitch.get("pitchNum")
|
||||
for re_ in (event.get("runnerEvents") or []):
|
||||
if re_.get("pitchNum") == pitch_num:
|
||||
return [re_]
|
||||
return []
|
||||
|
||||
|
||||
def get_last_pitch_result_text(event: dict[str, Any] | None) -> str:
|
||||
"""이벤트의 마지막 투구 결과 텍스트 반환"""
|
||||
if not event:
|
||||
return ""
|
||||
pitches = event.get("pitches") or []
|
||||
if not pitches:
|
||||
return ""
|
||||
return (pitches[-1].get("pitchResultText") or "").strip()
|
||||
131
core/review_parser.py
Normal file
131
core/review_parser.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
core/review_parser.py — 합의판정/비디오판독 파싱
|
||||
|
||||
판독 텍스트에서 항목, 원래 판정, 최종 판정 등을 추출합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import review_result_groups
|
||||
|
||||
|
||||
def infer_review_item(detail_text: str) -> str:
|
||||
"""판독 텍스트에서 사이트 표준 항목 추론
|
||||
|
||||
'홈런 파울 판정' → '홈런타구 페어 파울'
|
||||
"""
|
||||
dt = detail_text.replace(" ", "")
|
||||
if "홈런" in dt:
|
||||
return "홈런타구 페어 파울"
|
||||
if "아웃" in dt or "세이프" in dt or "포스" in dt or "태그" in dt or "견제" in dt or "도루" in dt:
|
||||
return "포수/태그플레이 아웃/세이프"
|
||||
if "페어" in dt or "파울" in dt:
|
||||
return "외야타구 페어 파울"
|
||||
if "포구" in dt or "노바운드" in dt or "바운드" in dt:
|
||||
return "야수의 포구"
|
||||
if "몸에맞" in dt or "데드볼" in dt:
|
||||
return "몸에 맞는 볼"
|
||||
if "헛스윙" in dt or "스윙" in dt:
|
||||
return "헛스윙"
|
||||
return "기타"
|
||||
|
||||
|
||||
def normalize_review_result_token(token: str, review_item: str) -> str | None:
|
||||
"""판독 결과 토큰을 정규화
|
||||
|
||||
'세이프' → '세이프', '노스윙' → '불인정'
|
||||
"""
|
||||
token = (token or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if review_item in {"홈런타구 페어 파울", "외야타구 페어 파울"}:
|
||||
if "페어" in token:
|
||||
return "페어"
|
||||
if "파울" in token:
|
||||
return "파울"
|
||||
elif review_item in {"포수/태그플레이 아웃/세이프", "야수의 포구"}:
|
||||
if "아웃" in token:
|
||||
return "아웃"
|
||||
if "세이프" in token:
|
||||
return "세이프"
|
||||
elif review_item == "헛스윙":
|
||||
# '노스윙'에도 '스윙'이 포함되므로 먼저 체크
|
||||
if "불인정" in token or "노스윙" in token or "공포" in token or "노 스윙" in token:
|
||||
return "불인정"
|
||||
if "스윙" in token or "인정" in token:
|
||||
return "인정"
|
||||
else:
|
||||
if "불인정" in token or "실패" in token:
|
||||
return "불인정"
|
||||
if "인정" in token:
|
||||
return "인정"
|
||||
return token # 모르는 키워드 → 원문 그대로
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_review_event_text(text: str) -> dict[str, Any]:
|
||||
"""판독 텍스트를 파싱하여 구조화된 dict로 변환
|
||||
|
||||
입력 예: '6회초 8번타순 1구 후 18:45 ~ 18:46 (1분간) LG요청
|
||||
비디오 판독: 안중열 포스아웃 관련 세이프→세이프'
|
||||
"""
|
||||
inning_match = re.search(r"(\d+)회(초|말)", text)
|
||||
request_team_match = re.search(r"([가-힣A-Za-z]+)요청\s*(?:비디오 판독|합의 판정)", text)
|
||||
|
||||
# '→노 스윙' 같은 공백 정규화
|
||||
normalized = re.sub(r"→([가-힣]+)\s+([가-힣]+)", r"→\1\2", text)
|
||||
detail_match = re.search(
|
||||
r"(?:비디오 판독|합의 판정):\s*(.+?)\s*([가-힣]+)→([가-힣]+)\s*$",
|
||||
normalized,
|
||||
)
|
||||
|
||||
detail_text = detail_match.group(1).strip() if detail_match else text
|
||||
review_item = infer_review_item(detail_text)
|
||||
before_result = normalize_review_result_token(detail_match.group(2), review_item) if detail_match else None
|
||||
after_result = normalize_review_result_token(detail_match.group(3), review_item) if detail_match else None
|
||||
|
||||
return {
|
||||
"type": "video_review",
|
||||
"text": text,
|
||||
"requestInningLabel": (
|
||||
f"{inning_match.group(1)}{'초' if inning_match.group(2) == '초' else '말'}"
|
||||
if inning_match else None
|
||||
),
|
||||
"requestTeam": request_team_match.group(1) if request_team_match else None,
|
||||
"reviewItem": review_item,
|
||||
"beforeResult": before_result,
|
||||
"finalResult": after_result,
|
||||
"isSuccess": (
|
||||
"성공" if before_result and after_result and before_result != after_result
|
||||
else "실패"
|
||||
),
|
||||
"timing": "before_pitch" if "초구 전" in text else "after_pitch",
|
||||
}
|
||||
|
||||
|
||||
def normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""판독 이벤트를 정규화
|
||||
|
||||
beforeResult/finalResult가 누락된 경우 텍스트에서 재파싱
|
||||
"""
|
||||
has_results = (
|
||||
review_event.get("beforeResult") is not None
|
||||
and review_event.get("finalResult") is not None
|
||||
)
|
||||
if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results:
|
||||
return review_event
|
||||
|
||||
text = review_event.get("text") or ""
|
||||
parsed = parse_review_event_text(text)
|
||||
parsed.update({k: v for k, v in review_event.items() if k not in parsed})
|
||||
return parsed
|
||||
|
||||
|
||||
def get_review_result_group(review_item: str) -> dict[str, Any] | None:
|
||||
"""사이트에서 판독항목에 대응하는 결과 그룹 정보 반환"""
|
||||
groups = review_result_groups()
|
||||
return groups.get(review_item)
|
||||
133
core/runner_classifier.py
Normal file
133
core/runner_classifier.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
core/runner_classifier.py — 주루 이벤트 분류
|
||||
|
||||
네이버 리포트의 주루 이벤트를 분석하여 관리자 사이트에서
|
||||
선택해야 할 라디오 버튼 라벨을 결정합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import runner_event_map
|
||||
|
||||
|
||||
def classify_runner_event(event_type: str) -> str | None:
|
||||
"""주루 이벤트 타입 → 사이트 라벨 (기본 매핑)"""
|
||||
return runner_event_map().get(event_type or "")
|
||||
|
||||
|
||||
def infer_runner_action_label(
|
||||
event: dict[str, Any],
|
||||
runner_event: dict[str, Any],
|
||||
) -> str | None:
|
||||
"""주루 이벤트를 종합적으로 추론하여 사이트 라벨 반환
|
||||
|
||||
리포트 action_label, event_type, event_text, result_type 등을 모두 분석.
|
||||
"""
|
||||
# 0. 리포트에 명시된 라벨이 있으면 최우선
|
||||
if "action_label" in runner_event:
|
||||
return runner_event["action_label"]
|
||||
|
||||
event_type = runner_event.get("type") or ""
|
||||
event_text = runner_event.get("text") or ""
|
||||
result_type = ((event.get("result") or {}).get("type") or "")
|
||||
result_text = ((event.get("result") or {}).get("text") or "")
|
||||
|
||||
# 이중도루 실패 + 진루
|
||||
if "이중도루 실패" in event_text and "진루" in event_text:
|
||||
return "기타 진루"
|
||||
if "도루" in event_text and "실패" in event_text and "진루" in event_text:
|
||||
return "기타 진루"
|
||||
|
||||
# 견제 아웃
|
||||
if event_type == "pickoff_out" or "견제사" in event_text:
|
||||
return "견제 아웃"
|
||||
|
||||
# 도루 실패
|
||||
if event_type == "steal_fail":
|
||||
return "도루시도 아웃"
|
||||
if "이중도루 실패" in event_text and "아웃" in event_text:
|
||||
return "도루시도 아웃"
|
||||
|
||||
# 도루 + 실책 진루
|
||||
if "도루" in event_text and "실책" in event_text and ("진루" in event_text or event_type == "error_advance"):
|
||||
return "도루성공&실책"
|
||||
|
||||
# 도루
|
||||
if "도루" in event_text:
|
||||
if "실패" in event_text:
|
||||
return "도루시도 아웃"
|
||||
return "도루성공"
|
||||
|
||||
# 낫아웃 + 폭투/포일
|
||||
if "낫아웃" in result_text and event_type == "wild_pitch_advance":
|
||||
return "폭투 낫아웃 진루"
|
||||
if "낫아웃" in result_text and event_type == "passed_ball_advance":
|
||||
return "포일 낫아웃 진루"
|
||||
|
||||
# 포일 진루
|
||||
if "포일" in event_text and ("진루" in event_text or event_type == "passed_ball_advance"):
|
||||
return "포일-진루성공"
|
||||
|
||||
# 실책으로 진루
|
||||
if "실책으로" in event_text:
|
||||
return "수비 실책"
|
||||
|
||||
# 안타/아웃 상황 → 일반 진루
|
||||
play_types = {
|
||||
"single", "double", "triple", "home_run", "out", "strikeout",
|
||||
"play", "sacrifice_fly", "sacrifice_bunt", "ground_out", "fly_out",
|
||||
}
|
||||
if result_type in play_types and event_type in {"advance", "score"}:
|
||||
return "일반 진루"
|
||||
|
||||
# 볼넷 상황 → 볼넷 진루
|
||||
walk_types = {"walk", "intentional_walk", "hit_by_pitch"}
|
||||
if result_type in walk_types and event_type in {"advance", "score"}:
|
||||
return "볼넷 진루"
|
||||
|
||||
# 기본: 일반 진루
|
||||
if event_type in {"advance", "score"}:
|
||||
return "일반 진루"
|
||||
|
||||
# 최종 폴백: config 매핑
|
||||
return classify_runner_event(event_type)
|
||||
|
||||
|
||||
def get_runner_area_type(event: dict[str, Any], runner_event: dict[str, Any]) -> int:
|
||||
"""주루 이벤트의 입력 영역 타입 결정
|
||||
|
||||
1 = 진루 영역 (일반 진루, 볼넷 진루 등)
|
||||
2 = 액션 영역 (도루, 견제, 폭투, 포일 등)
|
||||
"""
|
||||
event_text = runner_event.get("text") or ""
|
||||
action_keywords = ["도루", "견제", "폭투", "포일", "태그아웃", "포스아웃"]
|
||||
if any(k in event_text for k in action_keywords):
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def split_complex_runner_event(
|
||||
runner_event: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""복합 주루 이벤트를 두 개로 분리
|
||||
|
||||
예: '도루성공 후 수비실책 진루' → (도루, 실책진루)
|
||||
"""
|
||||
text = runner_event.get("text") or ""
|
||||
|
||||
if "실책" not in text and "/" not in text:
|
||||
return runner_event, None
|
||||
|
||||
# '도루성공&실책' 같은 패턴
|
||||
if "도루" in text and "실책" in text and "진루" in text:
|
||||
first = dict(runner_event)
|
||||
first["type"] = "steal"
|
||||
first["text"] = text
|
||||
|
||||
second = dict(runner_event)
|
||||
second["type"] = "error_advance"
|
||||
second["text"] = text
|
||||
return first, second
|
||||
|
||||
return runner_event, None
|
||||
6
crawler/__init__.py
Normal file
6
crawler/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
crawler/ — 네이버 스포츠 API 크롤링 패키지
|
||||
|
||||
네이버 API에서 데이터를 수집하고, relay 데이터를 파싱하여
|
||||
정규화된 JSON 리포트를 생성합니다.
|
||||
"""
|
||||
116
crawler/lineup_builder.py
Normal file
116
crawler/lineup_builder.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
crawler/lineup_builder.py — 라인업 데이터 구성
|
||||
|
||||
relay 데이터와 preview 데이터에서 라인업 정보를 추출합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crawler.naver_api import get_team_names
|
||||
|
||||
|
||||
def get_starting_pitcher(pitchers: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""투수 리스트에서 선발투수 추출"""
|
||||
if not pitchers:
|
||||
return None
|
||||
return min(pitchers, key=lambda p: p.get("seqno", 999))
|
||||
|
||||
|
||||
def get_starting_batters(batters: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""타자 리스트에서 선발 라인업 추출"""
|
||||
starters_by_order: dict[int, dict[str, Any]] = {}
|
||||
for batter in sorted(batters, key=lambda b: (b.get("batOrder", 999), b.get("seqno", 999))):
|
||||
bat_order = batter.get("batOrder")
|
||||
if bat_order is None or bat_order in starters_by_order:
|
||||
continue
|
||||
starters_by_order[bat_order] = batter
|
||||
return [starters_by_order[order] for order in sorted(starters_by_order)]
|
||||
|
||||
|
||||
def build_lineup_team(team_name: str, lineup: dict[str, Any]) -> dict[str, Any]:
|
||||
"""relay 데이터의 라인업 → 정규화된 팀 라인업 dict"""
|
||||
starter_pitcher = get_starting_pitcher(lineup.get("pitcher", []))
|
||||
starting_batters = get_starting_batters(lineup.get("batter", []))
|
||||
return {
|
||||
"team_name": team_name,
|
||||
"starter_pitcher": {
|
||||
"name": starter_pitcher.get("name"),
|
||||
"position": "투수",
|
||||
"number": starter_pitcher.get("backnum"),
|
||||
}
|
||||
if starter_pitcher
|
||||
else None,
|
||||
"players": [
|
||||
{
|
||||
"bat_order": batter.get("batOrder"),
|
||||
"name": batter.get("name"),
|
||||
"position": batter.get("posName"),
|
||||
"number": batter.get("backnum"),
|
||||
}
|
||||
for batter in starting_batters
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_preview_lineup_team(
|
||||
team_name: str, preview_lineup: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""preview 데이터의 라인업 → 정규화된 팀 라인업 dict"""
|
||||
if not preview_lineup:
|
||||
return None
|
||||
|
||||
full_lineup = preview_lineup.get("fullLineUp") or []
|
||||
starter_pitcher = next(
|
||||
(
|
||||
player
|
||||
for player in full_lineup
|
||||
if player.get("positionName") == "선발투수"
|
||||
or int(player.get("batorder", 0) or 0) == 0
|
||||
),
|
||||
None,
|
||||
)
|
||||
batters = sorted(
|
||||
(player for player in full_lineup if int(player.get("batorder", 0) or 0) > 0),
|
||||
key=lambda p: int(p.get("batorder", 99) or 99),
|
||||
)
|
||||
|
||||
return {
|
||||
"team_name": team_name,
|
||||
"starter_pitcher": {
|
||||
"name": starter_pitcher.get("playerName"),
|
||||
"position": "투수",
|
||||
"number": starter_pitcher.get("backnum"),
|
||||
}
|
||||
if starter_pitcher
|
||||
else None,
|
||||
"players": [
|
||||
{
|
||||
"bat_order": int(player.get("batorder")),
|
||||
"name": player.get("playerName"),
|
||||
"position": player.get("positionName"),
|
||||
"number": player.get("backnum"),
|
||||
}
|
||||
for player in batters
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_lineup_summary(
|
||||
game_id: str,
|
||||
game_info: dict[str, Any],
|
||||
relay_data: dict[str, Any],
|
||||
preview_data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""전체 라인업 요약 생성 (preview 우선, relay 폴백)"""
|
||||
away_name, home_name = get_team_names(game_id, game_info)
|
||||
away_preview = build_preview_lineup_team(
|
||||
away_name, (preview_data or {}).get("awayTeamLineUp"),
|
||||
)
|
||||
home_preview = build_preview_lineup_team(
|
||||
home_name, (preview_data or {}).get("homeTeamLineUp"),
|
||||
)
|
||||
return {
|
||||
"away_team": away_preview or build_lineup_team(away_name, relay_data["awayLineup"]),
|
||||
"home_team": home_preview or build_lineup_team(home_name, relay_data["homeLineup"]),
|
||||
}
|
||||
197
crawler/naver_api.py
Normal file
197
crawler/naver_api.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
crawler/naver_api.py — 네이버 스포츠 API HTTP 클라이언트
|
||||
|
||||
모든 네이버 API 호출을 캡슐화합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config_loader import (
|
||||
crawler_headers,
|
||||
game_type_map,
|
||||
kbo_sr_id_candidates,
|
||||
result_labels,
|
||||
team_code_map,
|
||||
)
|
||||
|
||||
BASE_URL = "https://api-gw.sports.naver.com/schedule/games"
|
||||
KBO_URL = "https://www.koreabaseball.com/ws/Schedule.asmx/GetScoreBoardScroll"
|
||||
|
||||
|
||||
class NaverApiClient:
|
||||
"""네이버 스포츠 API 클라이언트
|
||||
|
||||
httpx.Client를 래핑하여 게임 정보, relay, 라인업, 기록 등을 가져옵니다.
|
||||
with 문으로 사용하세요:
|
||||
|
||||
with NaverApiClient() as api:
|
||||
relay = api.fetch_relay(game_id)
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: float = 20.0):
|
||||
self._client: httpx.Client | None = None
|
||||
self._timeout = timeout
|
||||
|
||||
def __enter__(self) -> "NaverApiClient":
|
||||
self._client = httpx.Client(headers=crawler_headers(), timeout=self._timeout)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
raise RuntimeError("NaverApiClient는 with 문 안에서 사용하세요.")
|
||||
return self._client
|
||||
|
||||
def _get_json(self, url: str) -> dict[str, Any]:
|
||||
resp = self.client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 게임 정보
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def fetch_game_info(self, game_id: str) -> dict[str, Any]:
|
||||
"""게임 기본 정보"""
|
||||
payload = self._get_json(f"{BASE_URL}/{game_id}")
|
||||
return payload["result"]["game"]
|
||||
|
||||
def fetch_relay(self, game_id: str, inning: int | None = None) -> dict[str, Any]:
|
||||
"""relay 데이터 (전체 또는 특정 이닝)"""
|
||||
url = f"{BASE_URL}/{game_id}/relay"
|
||||
if inning is not None:
|
||||
url += f"?inning={inning}"
|
||||
payload = self._get_json(url)
|
||||
return payload["result"]["textRelayData"]
|
||||
|
||||
def fetch_record(self, game_id: str) -> dict[str, Any]:
|
||||
"""기록 데이터 (투수/타자 기록)"""
|
||||
payload = self._get_json(f"{BASE_URL}/{game_id}/record?fields=all")
|
||||
return payload["result"]["recordData"]
|
||||
|
||||
def fetch_preview(self, game_id: str) -> dict[str, Any]:
|
||||
"""프리뷰 데이터 (예비 라인업 포함)"""
|
||||
payload = self._get_json(f"{BASE_URL}/{game_id}/preview")
|
||||
return payload["result"].get("previewData") or {}
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# KBO 공식 사이트 데이터
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def fetch_kbo_review_meta(
|
||||
self, game_id: str, game_info: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""KBO 공식 사이트에서 종료시간/관중수 등 메타 정보 조회"""
|
||||
game_type = infer_game_type(game_info)
|
||||
candidates = kbo_sr_id_candidates().get(game_type, kbo_sr_id_candidates()["정규경기"])
|
||||
kbo_game_id = to_kbo_game_id(game_id)
|
||||
|
||||
for sr_id in candidates:
|
||||
resp = self.client.post(
|
||||
KBO_URL,
|
||||
data={
|
||||
"leId": "1",
|
||||
"srId": sr_id,
|
||||
"seasonId": str(game_info.get("seasonYear") or ""),
|
||||
"gameId": kbo_game_id,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if str(payload.get("code")) != "100":
|
||||
continue
|
||||
if not any(payload.get(key) for key in ("END_TM", "START_TM", "USE_TM", "CROWD_CN")):
|
||||
continue
|
||||
return payload
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 유틸리티 함수 (순수)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def clean_game_id(game_id: str) -> str:
|
||||
"""game_id에서 알파벳+숫자만 추출"""
|
||||
return "".join(re.findall(r"[A-Za-z0-9]", game_id))
|
||||
|
||||
|
||||
def get_team_names(
|
||||
game_id: str, game_info: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""game_id 또는 game_info에서 원정/홈 팀명 추출"""
|
||||
if game_info:
|
||||
return game_info["awayTeamName"], game_info["homeTeamName"]
|
||||
code_map = team_code_map()
|
||||
away_code = game_id[8:10]
|
||||
home_code = game_id[10:12]
|
||||
return code_map.get(away_code, away_code), code_map.get(home_code, home_code)
|
||||
|
||||
|
||||
def infer_game_type(game_info: dict[str, Any]) -> str:
|
||||
"""게임 정보에서 경기유형 추론"""
|
||||
round_code = str(game_info.get("roundCode") or "").lower()
|
||||
round_name = str(game_info.get("roundName") or "").strip()
|
||||
if round_name:
|
||||
return round_name
|
||||
gt_map = game_type_map()
|
||||
for key, label in gt_map.items():
|
||||
if key in round_code:
|
||||
return label
|
||||
return "정규경기"
|
||||
|
||||
|
||||
def to_kbo_game_id(game_id: str) -> str:
|
||||
"""네이버 game_id → KBO 공식 game_id"""
|
||||
return f"{game_id[:12]}0"
|
||||
|
||||
|
||||
def build_iso_datetime(game_date: str | None, hhmm: str | None) -> str | None:
|
||||
"""날짜 + 시:분 → ISO datetime 문자열"""
|
||||
if not game_date or not hhmm:
|
||||
return None
|
||||
time_text = hhmm.strip()
|
||||
if not time_text or ":" not in time_text:
|
||||
return None
|
||||
hour_text, minute_text = time_text.split(":", 1)
|
||||
try:
|
||||
dt = datetime.fromisoformat(f"{game_date}T{int(hour_text):02d}:{int(minute_text):02d}:00")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def derive_umpires(record_data: dict[str, Any]) -> dict[str, str | None]:
|
||||
"""기록 데이터에서 심판 정보 추출"""
|
||||
umpire_record = next(
|
||||
(item for item in record_data.get("etcRecords", []) if item.get("how") == "심판"),
|
||||
None,
|
||||
)
|
||||
names = umpire_record.get("result", "").split() if umpire_record else []
|
||||
return {
|
||||
"chief": names[0] if len(names) > 0 else None,
|
||||
"first_base": names[1] if len(names) > 1 else None,
|
||||
"second_base": names[2] if len(names) > 2 else None,
|
||||
"third_base": names[3] if len(names) > 3 else None,
|
||||
}
|
||||
|
||||
|
||||
def extract_pitching_summary(record_data: dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""기록 데이터에서 투수 결과 요약 추출"""
|
||||
label_map = result_labels()
|
||||
summary: dict[str, list[str]] = {"승리투수": [], "패전투수": [], "홀드": [], "세이브": []}
|
||||
for pitcher in record_data.get("pitchingResult", []):
|
||||
label = label_map.get(pitcher.get("wls"))
|
||||
if label and label in summary:
|
||||
summary[label].append(pitcher["name"])
|
||||
return summary
|
||||
535
crawler/relay_parser.py
Normal file
535
crawler/relay_parser.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
crawler/relay_parser.py — relay 데이터 파싱
|
||||
|
||||
네이버 textRelays를 분석하여 이닝별/타석별 구조화된 이벤트로 변환합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import (
|
||||
skip_option_types,
|
||||
hidden_event_texts,
|
||||
change_keywords,
|
||||
max_inning,
|
||||
)
|
||||
from core.review_parser import parse_review_event_text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 정렬 키
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _option_seqno(option: dict[str, Any]) -> int:
|
||||
return int(option.get("seqno", -1))
|
||||
|
||||
|
||||
def _relay_seqno(relay: dict[str, Any]) -> int:
|
||||
seqnos = [
|
||||
_option_seqno(opt)
|
||||
for opt in relay.get("textOptions", [])
|
||||
if opt.get("seqno") is not None
|
||||
]
|
||||
return min(seqnos) if seqnos else -1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 제목 추출
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_half_inning_title(
|
||||
relays: list[dict[str, Any]], inning: int, home_or_away: int,
|
||||
) -> str:
|
||||
"""이닝 시작 릴레이에서 제목 추출"""
|
||||
for relay in relays:
|
||||
for opt in relay.get("textOptions", []):
|
||||
if opt.get("type") == 0:
|
||||
return opt.get("text", "").strip()
|
||||
half_label = "초" if home_or_away == 0 else "말"
|
||||
return f"{inning}회{half_label}"
|
||||
|
||||
|
||||
def _get_batter_title(relay: dict[str, Any], options: list[dict[str, Any]]) -> str:
|
||||
"""릴레이 블록에서 타자 이름/제목 추출"""
|
||||
batter_title = next(
|
||||
(opt.get("text", "").strip() for opt in options if opt.get("type") == 8),
|
||||
"",
|
||||
)
|
||||
if batter_title:
|
||||
return batter_title
|
||||
title = (relay.get("title") or "").strip()
|
||||
if title and "공격" not in title and not title.startswith("="):
|
||||
return title
|
||||
return ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 투구/주루/교체 파싱
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _format_pitch_text(option: dict[str, Any]) -> str:
|
||||
"""투구 옵션 → 포맷된 텍스트"""
|
||||
text = option.get("text", "").strip()
|
||||
speed = str(option.get("speed") or "").strip()
|
||||
stuff = str(option.get("stuff") or "").strip()
|
||||
details = []
|
||||
if speed:
|
||||
details.append(f"{speed}km")
|
||||
if stuff:
|
||||
details.append(stuff)
|
||||
return f"{text} ({', '.join(details)})" if details else text
|
||||
|
||||
|
||||
def _classify_pitch_result(text: str, code: str | None) -> str:
|
||||
"""투구 결과 텍스트 + 코드 → 정규화된 결과 코드"""
|
||||
normalized = text.replace(" ", "")
|
||||
if any(key in normalized for key in ("번트헛스윙", "헛스윙번트", "번트시도스트라이크")):
|
||||
return "BS"
|
||||
if any(key in normalized for key in ("번트파울", "번트파울.")):
|
||||
return "BF"
|
||||
if code in {"BS", "BF", "B", "T", "S", "F", "H"}:
|
||||
return code
|
||||
if code and code != "V":
|
||||
return code
|
||||
mapping = {
|
||||
"번트 헛스윙": "BS",
|
||||
"번트헛스윙": "BS",
|
||||
"번트 파울": "BF",
|
||||
"번트파울": "BF",
|
||||
"볼": "B",
|
||||
"스트라이크": "T",
|
||||
"헛스윙": "S",
|
||||
"파울": "F",
|
||||
"타격": "H",
|
||||
}
|
||||
for key, value in mapping.items():
|
||||
if key in text:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_result_type(text: str) -> str:
|
||||
"""결과 텍스트 → result.type 코드"""
|
||||
clean_text = text.replace(" ", "")
|
||||
if "낫아웃" in clean_text:
|
||||
return "strikeout_not_out"
|
||||
if "고의사구" in text:
|
||||
return "intentional_walk"
|
||||
if "볼넷" in text:
|
||||
return "walk"
|
||||
if "삼진" in text:
|
||||
return "strikeout"
|
||||
if any(k in text for k in ["몸에 맞는 볼", "몸에 맞는 공", "사구", "헤드샷"]):
|
||||
return "hit_by_pitch"
|
||||
if "홈런" in text:
|
||||
return "home_run"
|
||||
if "3루타" in text:
|
||||
return "triple"
|
||||
if "2루타" in text:
|
||||
return "double"
|
||||
if "번트안타" in text:
|
||||
return "bunt_hit"
|
||||
if "1루타" in text or "내야안타" in text:
|
||||
return "single"
|
||||
if "실책" in text and "출루" in text:
|
||||
return "reach_on_error"
|
||||
if "야수선택" in text:
|
||||
return "reach_on_fielder_choice"
|
||||
if "땅볼로 출루" in text or "땅볼출루" in text:
|
||||
return "reach_on_grounder"
|
||||
if "희생번트" in text:
|
||||
return "sacrifice_bunt"
|
||||
if "희생플라이" in text:
|
||||
return "sacrifice_fly"
|
||||
if "병살타" in text:
|
||||
return "double_play"
|
||||
if any(k in text for k in [
|
||||
"플라이 아웃", "땅볼 아웃", "인필드플라이 아웃",
|
||||
"라인드라이브 아웃", "직선타 아웃", "라인드라이브", "직선타",
|
||||
]):
|
||||
return "out"
|
||||
return "play"
|
||||
|
||||
|
||||
def _parse_runner_event(text: str) -> dict[str, Any]:
|
||||
"""주루 이벤트 텍스트 → 구조화된 dict"""
|
||||
event_type = "runner_event"
|
||||
if "도루" in text:
|
||||
event_type = "steal_fail" if "실패" in text else "steal"
|
||||
elif "홈인" in text:
|
||||
event_type = "score"
|
||||
elif "포스아웃" in text:
|
||||
event_type = "force_out"
|
||||
elif "견제사" in text:
|
||||
event_type = "pickoff_out"
|
||||
elif "태그아웃" in text:
|
||||
event_type = "tag_out"
|
||||
elif "실책" in text:
|
||||
event_type = "error_advance"
|
||||
elif "폭투" in text:
|
||||
event_type = "wild_pitch_advance"
|
||||
elif "포일" in text:
|
||||
event_type = "passed_ball_advance"
|
||||
elif "진루" in text:
|
||||
event_type = "advance"
|
||||
|
||||
from_base = None
|
||||
to_base = None
|
||||
for label, base in (("1루주자", 1), ("2루주자", 2), ("3루주자", 3), ("1루", 1), ("2루", 2), ("3루", 3)):
|
||||
if label in text and from_base is None:
|
||||
from_base = base
|
||||
for label, base in (("1루까지", 1), ("2루까지", 2), ("3루까지", 3)):
|
||||
if label in text:
|
||||
to_base = base
|
||||
if "홈인" in text:
|
||||
to_base = 4
|
||||
|
||||
runner_name = (
|
||||
text.split(" : ", 1)[0]
|
||||
.replace("1루주자 ", "")
|
||||
.replace("2루주자 ", "")
|
||||
.replace("3루주자 ", "")
|
||||
.replace("대주자 ", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
extra_advance = 0
|
||||
if "주자의 재치로" in text and from_base is not None and to_base is not None:
|
||||
extra_advance = max(0, to_base - from_base)
|
||||
|
||||
# action_label: 관리자 사이트 버튼 라벨 매핑
|
||||
clean_text = text.replace(" ", "")
|
||||
if "실책으로" in clean_text:
|
||||
action_label = "수비 실책"
|
||||
elif "도루" in clean_text:
|
||||
action_label = "도루성공" if "실패" not in clean_text else "도루시도 아웃"
|
||||
elif "폭투" in clean_text:
|
||||
action_label = "폭투-진루성공"
|
||||
elif "포일" in clean_text:
|
||||
action_label = "포일-진루성공"
|
||||
elif "태그" in clean_text:
|
||||
action_label = "태그아웃"
|
||||
elif "포스" in clean_text:
|
||||
action_label = "포스아웃"
|
||||
elif "견제" in clean_text:
|
||||
action_label = "견제 아웃"
|
||||
elif any(k in clean_text for k in ["볼넷", "포볼", "고의사구", "몸에맞는", "사구"]):
|
||||
action_label = "볼넷 진루"
|
||||
else:
|
||||
action_label = "일반 진루"
|
||||
|
||||
return {
|
||||
"type": event_type,
|
||||
"runner": runner_name,
|
||||
"fromBase": from_base,
|
||||
"toBase": to_base,
|
||||
"extra_advance": extra_advance,
|
||||
"text": text,
|
||||
"action_label": action_label,
|
||||
}
|
||||
|
||||
|
||||
def _parse_change_event(text: str) -> dict[str, Any]:
|
||||
"""교체 텍스트 → 구조화된 dict"""
|
||||
event: dict[str, Any] = {
|
||||
"event_type": "change",
|
||||
"change_type": "position_change" if "수비위치 변경" in text else "substitution",
|
||||
"text": text,
|
||||
}
|
||||
actor_role, batter_order, actor_name = _extract_change_actor(text)
|
||||
event["actor_role"] = actor_role
|
||||
event["actor_name"] = actor_name
|
||||
if batter_order:
|
||||
event["bat_order"] = int(batter_order)
|
||||
|
||||
if "수비위치 변경" in text:
|
||||
to_position = text.split(" : ", 1)[1].split("(으)로", 1)[0].strip()
|
||||
event["player_name"] = actor_name
|
||||
event["to_position"] = to_position
|
||||
return event
|
||||
|
||||
rhs = text.split(" : ", 1)[1].split("(으)로 교체", 1)[0].strip()
|
||||
in_role, _, in_name = _extract_change_actor(rhs)
|
||||
event["out_player"] = actor_name
|
||||
event["in_player"] = in_name
|
||||
event["in_role"] = in_role
|
||||
|
||||
field_roles = {"투수", "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"}
|
||||
if actor_role in field_roles and in_role == "투수":
|
||||
event["change_type"] = "merged_pitcher_substitution"
|
||||
event["player_name"] = actor_name
|
||||
event["to_position"] = "지명타자"
|
||||
event["pitcher_in_player"] = in_name
|
||||
return event
|
||||
|
||||
extra_roles = field_roles | {"대타", "대주자"}
|
||||
if in_role in extra_roles:
|
||||
event["to_position"] = in_role if in_role not in {"대타", "대주자"} else None
|
||||
return event
|
||||
|
||||
|
||||
def _extract_change_actor(text: str) -> tuple[str | None, str | None, str]:
|
||||
"""교체 텍스트에서 역할/타순/이름 추출"""
|
||||
lhs = text.split(" : ", 1)[0].strip()
|
||||
if "번타자 " in lhs:
|
||||
order_match = re.search(r"(\d+)번타자\s+(.+)$", lhs)
|
||||
if order_match:
|
||||
return "batter", order_match.group(1), order_match.group(2).strip()
|
||||
for role in (
|
||||
"대타", "대주자", "1루주자", "2루주자", "3루주자", "주자",
|
||||
"투수", "포수", "1루수", "2루수", "3루수",
|
||||
"유격수", "좌익수", "중견수", "우익수",
|
||||
):
|
||||
if lhs.startswith(role + " "):
|
||||
return role, None, lhs[len(role):].strip()
|
||||
return None, None, lhs
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 주루 이벤트 병합
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _merge_runner_events(runner_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""동일 주자의 이벤트를 병합"""
|
||||
merged: dict[str, dict[str, Any]] = {}
|
||||
for r in runner_events:
|
||||
name = r.get("runner")
|
||||
if not name:
|
||||
continue
|
||||
if name in merged:
|
||||
merged[name]["type"] = r.get("type", merged[name]["type"])
|
||||
merged[name]["text"] += f" / {r.get('text', '')}"
|
||||
if r.get("toBase"):
|
||||
merged[name]["toBase"] = r["toBase"]
|
||||
if r.get("extra_advance"):
|
||||
merged[name]["extra_advance"] = r["extra_advance"]
|
||||
if "태그아웃" in r.get("text", "") or r.get("type") == "tag_out":
|
||||
merged[name]["type"] = "tag_out"
|
||||
else:
|
||||
merged[name] = dict(r)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 릴레이 → 이벤트 리스트 변환
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def build_relay_events(relay: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""하나의 릴레이 블록 → 타석/교체 이벤트 리스트"""
|
||||
skip_types = skip_option_types()
|
||||
hidden_texts = hidden_event_texts()
|
||||
chg_keywords = change_keywords()
|
||||
|
||||
options = sorted(relay.get("textOptions", []), key=_option_seqno)
|
||||
|
||||
# 1. 세그먼트 분리 (pitchNum 1이 새로 나오면 타자가 바뀐 것)
|
||||
segments: list[list[dict[str, Any]]] = []
|
||||
current_segment: list[dict[str, Any]] = []
|
||||
|
||||
for opt in options:
|
||||
opt_type = opt.get("type")
|
||||
if opt_type == 1 and opt.get("pitchNum") == 1:
|
||||
if any(o.get("type") == 1 for o in current_segment):
|
||||
segments.append(current_segment)
|
||||
current_segment = []
|
||||
current_segment.append(opt)
|
||||
if current_segment:
|
||||
segments.append(current_segment)
|
||||
|
||||
# 2. 각 세그먼트별 이벤트 생성
|
||||
results: list[dict[str, Any]] = []
|
||||
relay_batter_title = _get_batter_title(relay, options)
|
||||
|
||||
for i, seg_options in enumerate(segments):
|
||||
seg_changes: list[dict[str, Any]] = []
|
||||
seg_event_texts: list[str] = []
|
||||
seg_pitches: list[dict[str, Any]] = []
|
||||
seg_runner_events: list[dict[str, Any]] = []
|
||||
seg_review_events: list[dict[str, Any]] = []
|
||||
seg_extra_events: list[dict[str, Any]] = []
|
||||
seg_result_text: str | None = None
|
||||
|
||||
seg_batter_name: str | None = next(
|
||||
(o.get("text", "").strip() for o in seg_options if o.get("type") == 8),
|
||||
None,
|
||||
)
|
||||
|
||||
for opt in seg_options:
|
||||
ot = opt.get("type")
|
||||
txt = opt.get("text", "").strip()
|
||||
if not txt or ot in skip_types:
|
||||
continue
|
||||
if txt in hidden_texts:
|
||||
continue
|
||||
if any(k in txt for k in chg_keywords):
|
||||
seg_changes.append(_parse_change_event(txt))
|
||||
continue
|
||||
|
||||
if ot == 1:
|
||||
seg_event_texts.append(_format_pitch_text(opt))
|
||||
seg_pitches.append({
|
||||
"pitchNo": opt.get("pitchNum"),
|
||||
"pitchResult": _classify_pitch_result(txt, opt.get("pitchResult")),
|
||||
"pitchResultText": txt.replace(f"{opt.get('pitchNum')}구 ", "", 1),
|
||||
"speedKmh": int(opt["speed"]) if opt.get("speed") not in (None, "") else None,
|
||||
"pitchType": opt.get("stuff"),
|
||||
"runnerEvents": [],
|
||||
})
|
||||
continue
|
||||
|
||||
if ot == 14:
|
||||
if seg_pitches:
|
||||
seg_pitches[-1]["runnerEvents"].append(_parse_runner_event(txt))
|
||||
else:
|
||||
seg_runner_events.append(_parse_runner_event(txt))
|
||||
continue
|
||||
if ot == 24:
|
||||
seg_runner_events.append(_parse_runner_event(txt))
|
||||
continue
|
||||
|
||||
seg_event_texts.append(txt)
|
||||
if "비디오 판독" in txt or "합의 판정" in txt:
|
||||
seg_review_events.append(parse_review_event_text(txt))
|
||||
elif "체크스윙" in txt:
|
||||
seg_extra_events.append({"type": "appeal_or_judgement", "text": txt})
|
||||
elif any(r in txt for r in ["1루주자", "2루주자", "3루주자", "대주자", "도루", "홈인", "포스아웃"]) or ("진루" in txt and "출루" not in txt):
|
||||
seg_runner_events.append(_parse_runner_event(txt))
|
||||
else:
|
||||
seg_result_text = txt
|
||||
if " : " in txt and seg_batter_name is None:
|
||||
name_part = txt.split(" : ", 1)[0].strip()
|
||||
if name_part and len(name_part) < 10:
|
||||
seg_batter_name = name_part
|
||||
|
||||
if not seg_batter_name:
|
||||
seg_batter_name = relay_batter_title if i == 0 else ""
|
||||
|
||||
# 주루 이벤트 병합
|
||||
for p in seg_pitches:
|
||||
p["runnerEvents"] = _merge_runner_events(p["runnerEvents"])
|
||||
seg_merged_runners = _merge_runner_events(seg_runner_events)
|
||||
|
||||
# 타자 결과 객체
|
||||
res_obj = None
|
||||
if seg_result_text:
|
||||
base_type = _classify_result_type(seg_result_text)
|
||||
res_obj = {"type": base_type, "text": seg_result_text}
|
||||
|
||||
b_name = seg_batter_name.split()[-1] if seg_batter_name else ""
|
||||
final_runners = []
|
||||
for r in seg_merged_runners:
|
||||
if b_name and r.get("runner") == b_name:
|
||||
if base_type in {"single", "double", "triple"}:
|
||||
r_type = r.get("type", "")
|
||||
if r_type in {"tag_out", "force_out", "steal_fail", "pickoff_out"}:
|
||||
res_obj["type"] = f"{base_type}_runner_out"
|
||||
elif r_type == "error_advance":
|
||||
res_obj["type"] = f"{base_type}_error_advance"
|
||||
if r.get("toBase"):
|
||||
res_obj["toBase"] = r["toBase"]
|
||||
if r.get("extra_advance"):
|
||||
res_obj["extra_advance"] = r["extra_advance"]
|
||||
else:
|
||||
final_runners.append(r)
|
||||
seg_merged_runners = final_runners
|
||||
|
||||
if seg_changes:
|
||||
results.extend(seg_changes)
|
||||
|
||||
if seg_event_texts:
|
||||
full_txt = (
|
||||
f"{seg_batter_name} : " + ", ".join(seg_event_texts)
|
||||
if seg_batter_name
|
||||
else ", ".join(seg_event_texts)
|
||||
)
|
||||
results.append({
|
||||
"event_type": "at_bat",
|
||||
"batter": seg_batter_name,
|
||||
"rawText": full_txt,
|
||||
"pitches": seg_pitches,
|
||||
"result": res_obj,
|
||||
"runnerEvents": seg_merged_runners,
|
||||
"reviewEvents": seg_review_events,
|
||||
"extraEvents": seg_extra_events,
|
||||
"changes": [],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 이닝 빌드
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def build_half_inning(
|
||||
inning: int, home_or_away: int, relays: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""한 이닝의 한 쪽(초/말) 데이터를 구성"""
|
||||
title = get_half_inning_title(relays, inning, home_or_away)
|
||||
raw_events: list[dict[str, Any]] = []
|
||||
|
||||
for relay in sorted(relays, key=_relay_seqno):
|
||||
raw_events.extend(build_relay_events(relay))
|
||||
|
||||
# 같은 타자의 연속 타석 병합
|
||||
merged_events: list[dict[str, Any]] = []
|
||||
for event in raw_events:
|
||||
if not merged_events or event.get("event_type") != "at_bat":
|
||||
merged_events.append(event)
|
||||
continue
|
||||
|
||||
prev = merged_events[-1]
|
||||
if prev.get("event_type") != "at_bat":
|
||||
merged_events.append(event)
|
||||
continue
|
||||
|
||||
current_pitches = event.get("pitches") or []
|
||||
first_pitch_no = current_pitches[0].get("pitchNo", 0) if current_pitches else 0
|
||||
is_same_batter = prev.get("batter") == event.get("batter")
|
||||
|
||||
if first_pitch_no > 1 or is_same_batter:
|
||||
prev["pitches"].extend(current_pitches)
|
||||
if event.get("result"):
|
||||
prev["result"] = event["result"]
|
||||
if event.get("rawText"):
|
||||
current_txt = event["rawText"]
|
||||
if " : " in current_txt:
|
||||
current_txt = current_txt.split(" : ", 1)[1]
|
||||
prev["rawText"] += " / " + current_txt
|
||||
prev["runnerEvents"].extend(event.get("runnerEvents") or [])
|
||||
prev["reviewEvents"].extend(event.get("reviewEvents") or [])
|
||||
prev["extraEvents"].extend(event.get("extraEvents") or [])
|
||||
continue
|
||||
|
||||
merged_events.append(event)
|
||||
|
||||
return {
|
||||
"inning": inning,
|
||||
"half": "top" if home_or_away == 0 else "bottom",
|
||||
"title": title,
|
||||
"events": merged_events,
|
||||
}
|
||||
|
||||
|
||||
def parse_inning_value(val: Any, default: float) -> float:
|
||||
"""이닝 인수 파싱 ('1T' → 1.0, '3B' → 3.5)"""
|
||||
if val is None:
|
||||
return default
|
||||
s = str(val).upper().strip()
|
||||
if not s:
|
||||
return default
|
||||
m = re.match(r"^(\d+)([TB]?)$", s)
|
||||
if not m:
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return default
|
||||
num = int(m.group(1))
|
||||
suffix = m.group(2)
|
||||
if suffix == "T":
|
||||
return float(num)
|
||||
if suffix == "B":
|
||||
return num + 0.5
|
||||
return float(num)
|
||||
270
crawler/report_builder.py
Normal file
270
crawler/report_builder.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
crawler/report_builder.py — 최종 JSON 리포트 생성
|
||||
|
||||
네이버 API 데이터를 수집하고, relay 파싱 결과를 합쳐서
|
||||
정규화된 게임 리포트 JSON을 생성/저장합니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import max_inning
|
||||
|
||||
from crawler.naver_api import (
|
||||
NaverApiClient,
|
||||
build_iso_datetime,
|
||||
clean_game_id,
|
||||
derive_umpires,
|
||||
extract_pitching_summary,
|
||||
get_team_names,
|
||||
infer_game_type,
|
||||
)
|
||||
from crawler.relay_parser import build_half_inning, parse_inning_value
|
||||
from crawler.lineup_builder import build_lineup_summary
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 이닝 데이터 수집
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def collect_inning_data(
|
||||
api: NaverApiClient,
|
||||
game_id: str,
|
||||
start_inning_val: str | None = None,
|
||||
end_inning_val: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""모든 이닝 relay 데이터를 수집하여 구조화"""
|
||||
innings: list[dict[str, Any]] = []
|
||||
raw_relays: list[dict[str, Any]] = []
|
||||
|
||||
start_score = parse_inning_value(start_inning_val, 0.0)
|
||||
end_score = parse_inning_value(end_inning_val, 99.0)
|
||||
|
||||
for inning in range(1, max_inning() + 1):
|
||||
try:
|
||||
relay_data = api.fetch_relay(game_id, inning=inning)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
relays = relay_data.get("textRelays", [])
|
||||
if not relays:
|
||||
break
|
||||
|
||||
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
|
||||
for relay in relays:
|
||||
grouped[int(relay.get("homeOrAway", -1))].append(relay)
|
||||
raw_relays.append(relay)
|
||||
|
||||
for home_or_away in (0, 1):
|
||||
half_relays = grouped.get(home_or_away, [])
|
||||
if not half_relays:
|
||||
continue
|
||||
current_score = inning + (0.5 if home_or_away == 1 else 0.0)
|
||||
if current_score < start_score or current_score > end_score:
|
||||
continue
|
||||
innings.append(build_half_inning(inning, home_or_away, half_relays))
|
||||
|
||||
return innings, raw_relays
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 점수 타임라인 & 블론세이브
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _collect_score_timeline(raw_relays: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
timeline: list[dict[str, Any]] = []
|
||||
for relay in raw_relays:
|
||||
for option in relay.get("textOptions", []):
|
||||
state = option.get("currentGameState") or {}
|
||||
if not state:
|
||||
continue
|
||||
timeline.append({
|
||||
"seqno": option.get("seqno"),
|
||||
"home_score": int(state.get("homeScore", 0)),
|
||||
"away_score": int(state.get("awayScore", 0)),
|
||||
})
|
||||
timeline.sort(key=lambda item: item["seqno"])
|
||||
return timeline
|
||||
|
||||
|
||||
def _collect_blown_saves(
|
||||
raw_relays: list[dict[str, Any]], away_name: str, home_name: str,
|
||||
) -> list[str]:
|
||||
timeline = _collect_score_timeline(raw_relays)
|
||||
blown_save_pitchers: list[str] = []
|
||||
|
||||
pitcher_entries: list[dict[str, Any]] = []
|
||||
for relay in raw_relays:
|
||||
inning = int(relay.get("inn", 0) or 0)
|
||||
if inning < 7:
|
||||
continue
|
||||
batting_side = int(relay.get("homeOrAway", -1))
|
||||
pitcher_team = "home" if batting_side == 0 else "away"
|
||||
pitcher_team_name = home_name if pitcher_team == "home" else away_name
|
||||
|
||||
for option in relay.get("textOptions", []):
|
||||
if option.get("type") != 2:
|
||||
continue
|
||||
player_change = option.get("playerChange") or {}
|
||||
in_player = player_change.get("inPlayer") or {}
|
||||
if in_player.get("playerPos") != "투수":
|
||||
continue
|
||||
state = option.get("currentGameState") or {}
|
||||
pitcher_entries.append({
|
||||
"name": in_player.get("playerName"),
|
||||
"team": pitcher_team,
|
||||
"team_name": pitcher_team_name,
|
||||
"entry_seqno": option.get("seqno"),
|
||||
"home_score": int(state.get("homeScore", 0)),
|
||||
"away_score": int(state.get("awayScore", 0)),
|
||||
})
|
||||
|
||||
for entry in pitcher_entries:
|
||||
team_score = entry["home_score"] if entry["team"] == "home" else entry["away_score"]
|
||||
opp_score = entry["away_score"] if entry["team"] == "home" else entry["home_score"]
|
||||
if team_score <= opp_score:
|
||||
continue
|
||||
for state in timeline:
|
||||
if state["seqno"] <= entry["entry_seqno"]:
|
||||
continue
|
||||
current_team = state["home_score"] if entry["team"] == "home" else state["away_score"]
|
||||
current_opp = state["away_score"] if entry["team"] == "home" else state["home_score"]
|
||||
if current_team <= current_opp:
|
||||
blown_save_pitchers.append(entry["name"])
|
||||
break
|
||||
|
||||
return sorted(set(blown_save_pitchers))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 게임 정보 빌드
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _build_game_info(
|
||||
game_info: dict[str, Any],
|
||||
record_data: dict[str, Any],
|
||||
review_meta: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
end_time = build_iso_datetime(game_info.get("gameDate"), review_meta.get("END_TM"))
|
||||
return {
|
||||
"date": game_info.get("gameDate"),
|
||||
"stadium": game_info.get("stadium"),
|
||||
"start_time": game_info.get("gameDateTime"),
|
||||
"end_time": end_time,
|
||||
"season": game_info.get("seasonYear"),
|
||||
"game_type": infer_game_type(game_info),
|
||||
"home_team": game_info.get("homeTeamName"),
|
||||
"away_team": game_info.get("awayTeamName"),
|
||||
"attendance": review_meta.get("CROWD_CN"),
|
||||
"umpires": derive_umpires(record_data),
|
||||
}
|
||||
|
||||
|
||||
def _build_pitcher_section(
|
||||
record_data: dict[str, Any],
|
||||
raw_relays: list[dict[str, Any]],
|
||||
away_name: str,
|
||||
home_name: str,
|
||||
) -> dict[str, list[str]]:
|
||||
summary = extract_pitching_summary(record_data)
|
||||
summary["블론세이브"] = _collect_blown_saves(raw_relays, away_name, home_name)
|
||||
return summary
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 리포트 빌드 & 저장
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def build_report(
|
||||
game_id: str,
|
||||
start_inning: str | None = None,
|
||||
end_inning: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""게임 ID로 전체 리포트 생성
|
||||
|
||||
네이버 API 4종 + KBO 메타를 수집하여 정규화된 JSON dict 반환.
|
||||
"""
|
||||
game_id = clean_game_id(game_id)
|
||||
|
||||
with NaverApiClient() as api:
|
||||
relay_data = api.fetch_relay(game_id)
|
||||
record_data = api.fetch_record(game_id)
|
||||
game_info = api.fetch_game_info(game_id)
|
||||
preview_data = api.fetch_preview(game_id)
|
||||
review_meta = api.fetch_kbo_review_meta(game_id, game_info)
|
||||
|
||||
lineup_summary = build_lineup_summary(game_id, game_info, relay_data, preview_data)
|
||||
innings, raw_relays = collect_inning_data(
|
||||
api, game_id,
|
||||
start_inning_val=start_inning,
|
||||
end_inning_val=end_inning,
|
||||
)
|
||||
pitcher_section = _build_pitcher_section(
|
||||
record_data, raw_relays,
|
||||
lineup_summary["away_team"]["team_name"],
|
||||
lineup_summary["home_team"]["team_name"],
|
||||
)
|
||||
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"game_info": _build_game_info(game_info, record_data, review_meta),
|
||||
"lineups": lineup_summary,
|
||||
"game_contents": innings,
|
||||
"pitching_summary": pitcher_section,
|
||||
}
|
||||
|
||||
|
||||
def filter_report(
|
||||
report: dict[str, Any],
|
||||
inning: str | None = None,
|
||||
lineup_only: bool = False,
|
||||
start_inning: str | None = None,
|
||||
end_inning: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""리포트에서 특정 이닝만 필터링"""
|
||||
filtered = json.loads(json.dumps(report, ensure_ascii=False))
|
||||
|
||||
if lineup_only:
|
||||
filtered["game_contents"] = []
|
||||
filtered["pitching_summary"] = {
|
||||
"승리투수": [], "패전투수": [], "홀드": [], "세이브": [], "블론세이브": [],
|
||||
}
|
||||
return filtered
|
||||
|
||||
start_v = parse_inning_value(start_inning, 0.0)
|
||||
end_v = parse_inning_value(end_inning, 99.0)
|
||||
|
||||
if inning is not None:
|
||||
iv = parse_inning_value(inning, 0.0)
|
||||
start_v = iv
|
||||
end_v = iv + 0.5
|
||||
|
||||
filtered["game_contents"] = [
|
||||
half
|
||||
for half in filtered.get("game_contents", [])
|
||||
if start_v <= (
|
||||
float(half.get("inning") or 0)
|
||||
+ (0.5 if half.get("half") == "bottom" else 0.0)
|
||||
) <= end_v
|
||||
]
|
||||
return filtered
|
||||
|
||||
|
||||
def save_report(
|
||||
report: dict[str, Any],
|
||||
output_dir: Path,
|
||||
output_json: Path | None = None,
|
||||
) -> Path:
|
||||
"""리포트를 JSON 파일로 저장"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
game_id = report["game_id"]
|
||||
json_path = output_json or (output_dir / f"{game_id}_report.json")
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return json_path
|
||||
BIN
my_profile/BrowserMetrics/BrowserMetrics-69F5A518-63E9.pma
Normal file
BIN
my_profile/BrowserMetrics/BrowserMetrics-69F5A518-63E9.pma
Normal file
Binary file not shown.
1
my_profile/ChromeFeatureState
Normal file
1
my_profile/ChromeFeatureState
Normal file
@@ -0,0 +1 @@
|
||||
{"disable-features":"AutoDeElevate,AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,OptimizationHints,PaintHolding,RenderDocument,ThirdPartyStoragePartitioning,Translate","enable-features":"CDPScreenshotNewSurface,UkmSamplingRate\u003CUkmSamplingRate","force-fieldtrial-params":"UkmSamplingRate.Sampled_NoSeed_Stable:_default_sampling/1000000","force-fieldtrials":"*SeedFileTrial/Default/UkmSamplingRate/Sampled_NoSeed_Stable"}
|
||||
BIN
my_profile/Default/Account Web Data
Normal file
BIN
my_profile/Default/Account Web Data
Normal file
Binary file not shown.
0
my_profile/Default/Account Web Data-journal
Normal file
0
my_profile/Default/Account Web Data-journal
Normal file
BIN
my_profile/Default/Affiliation Database
Normal file
BIN
my_profile/Default/Affiliation Database
Normal file
Binary file not shown.
0
my_profile/Default/Affiliation Database-journal
Normal file
0
my_profile/Default/Affiliation Database-journal
Normal file
2
my_profile/Default/BookmarkMergedSurfaceOrdering
Normal file
2
my_profile/Default/BookmarkMergedSurfaceOrdering
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
BIN
my_profile/Default/Cache/Cache_Data/13d9afa0c3ccf0e2_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/13d9afa0c3ccf0e2_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/20f8a7ac591e3cfb_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/20f8a7ac591e3cfb_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/36999aedce6b9850_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/36999aedce6b9850_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/3fdc6fba64f26c1c_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/3fdc6fba64f26c1c_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/63f351f1930af849_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/63f351f1930af849_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/66d04e225683fe99_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/66d04e225683fe99_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/7f62be85fe6088aa_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/7f62be85fe6088aa_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/a58e561b6854b4f4_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/a58e561b6854b4f4_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/b4b76c79593d683b_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/b4b76c79593d683b_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/b56cc14f6e606ea0_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/b56cc14f6e606ea0_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/cd46246e2a12c82d_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/cd46246e2a12c82d_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/f81e693f9ae23dc3_0
Normal file
BIN
my_profile/Default/Cache/Cache_Data/f81e693f9ae23dc3_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/index
Normal file
BIN
my_profile/Default/Cache/Cache_Data/index
Normal file
Binary file not shown.
BIN
my_profile/Default/Cache/Cache_Data/index-dir/the-real-index
Normal file
BIN
my_profile/Default/Cache/Cache_Data/index-dir/the-real-index
Normal file
Binary file not shown.
1
my_profile/Default/Cache/No_Vary_Search/journal.baj
Normal file
1
my_profile/Default/Cache/No_Vary_Search/journal.baj
Normal file
@@ -0,0 +1 @@
|
||||
$F~
|
||||
BIN
my_profile/Default/Cache/No_Vary_Search/snapshot.baf
Normal file
BIN
my_profile/Default/Cache/No_Vary_Search/snapshot.baf
Normal file
Binary file not shown.
0
my_profile/Default/ClientCertificates/LOCK
Normal file
0
my_profile/Default/ClientCertificates/LOCK
Normal file
0
my_profile/Default/ClientCertificates/LOG
Normal file
0
my_profile/Default/ClientCertificates/LOG
Normal file
BIN
my_profile/Default/Code Cache/js/16508a754b416371_0
Normal file
BIN
my_profile/Default/Code Cache/js/16508a754b416371_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Code Cache/js/749d48cfaad904d2_0
Normal file
BIN
my_profile/Default/Code Cache/js/749d48cfaad904d2_0
Normal file
Binary file not shown.
BIN
my_profile/Default/Code Cache/js/index
Normal file
BIN
my_profile/Default/Code Cache/js/index
Normal file
Binary file not shown.
BIN
my_profile/Default/Code Cache/js/index-dir/the-real-index
Normal file
BIN
my_profile/Default/Code Cache/js/index-dir/the-real-index
Normal file
Binary file not shown.
BIN
my_profile/Default/Code Cache/wasm/index
Normal file
BIN
my_profile/Default/Code Cache/wasm/index
Normal file
Binary file not shown.
BIN
my_profile/Default/Code Cache/wasm/index-dir/the-real-index
Normal file
BIN
my_profile/Default/Code Cache/wasm/index-dir/the-real-index
Normal file
Binary file not shown.
BIN
my_profile/Default/Cookies
Normal file
BIN
my_profile/Default/Cookies
Normal file
Binary file not shown.
0
my_profile/Default/Cookies-journal
Normal file
0
my_profile/Default/Cookies-journal
Normal file
BIN
my_profile/Default/DawnGraphiteCache/data_0
Normal file
BIN
my_profile/Default/DawnGraphiteCache/data_0
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnGraphiteCache/data_1
Normal file
BIN
my_profile/Default/DawnGraphiteCache/data_1
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnGraphiteCache/data_2
Normal file
BIN
my_profile/Default/DawnGraphiteCache/data_2
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnGraphiteCache/data_3
Normal file
BIN
my_profile/Default/DawnGraphiteCache/data_3
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnGraphiteCache/index
Normal file
BIN
my_profile/Default/DawnGraphiteCache/index
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnWebGPUCache/data_0
Normal file
BIN
my_profile/Default/DawnWebGPUCache/data_0
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnWebGPUCache/data_1
Normal file
BIN
my_profile/Default/DawnWebGPUCache/data_1
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnWebGPUCache/data_2
Normal file
BIN
my_profile/Default/DawnWebGPUCache/data_2
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnWebGPUCache/data_3
Normal file
BIN
my_profile/Default/DawnWebGPUCache/data_3
Normal file
Binary file not shown.
BIN
my_profile/Default/DawnWebGPUCache/index
Normal file
BIN
my_profile/Default/DawnWebGPUCache/index
Normal file
Binary file not shown.
1
my_profile/Default/Extension Rules/CURRENT
Normal file
1
my_profile/Default/Extension Rules/CURRENT
Normal file
@@ -0,0 +1 @@
|
||||
MANIFEST-000001
|
||||
0
my_profile/Default/Extension Rules/LOCK
Normal file
0
my_profile/Default/Extension Rules/LOCK
Normal file
2
my_profile/Default/Extension Rules/LOG
Normal file
2
my_profile/Default/Extension Rules/LOG
Normal file
@@ -0,0 +1,2 @@
|
||||
2026/05/02-16:17:44.440 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Rules since it was missing.
|
||||
2026/05/02-16:17:44.442 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Rules/MANIFEST-000001
|
||||
BIN
my_profile/Default/Extension Rules/MANIFEST-000001
Normal file
BIN
my_profile/Default/Extension Rules/MANIFEST-000001
Normal file
Binary file not shown.
1
my_profile/Default/Extension Scripts/CURRENT
Normal file
1
my_profile/Default/Extension Scripts/CURRENT
Normal file
@@ -0,0 +1 @@
|
||||
MANIFEST-000001
|
||||
0
my_profile/Default/Extension Scripts/LOCK
Normal file
0
my_profile/Default/Extension Scripts/LOCK
Normal file
2
my_profile/Default/Extension Scripts/LOG
Normal file
2
my_profile/Default/Extension Scripts/LOG
Normal file
@@ -0,0 +1,2 @@
|
||||
2026/05/02-16:17:44.442 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Scripts since it was missing.
|
||||
2026/05/02-16:17:44.444 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension Scripts/MANIFEST-000001
|
||||
BIN
my_profile/Default/Extension Scripts/MANIFEST-000001
Normal file
BIN
my_profile/Default/Extension Scripts/MANIFEST-000001
Normal file
Binary file not shown.
1
my_profile/Default/Extension State/CURRENT
Normal file
1
my_profile/Default/Extension State/CURRENT
Normal file
@@ -0,0 +1 @@
|
||||
MANIFEST-000001
|
||||
0
my_profile/Default/Extension State/LOCK
Normal file
0
my_profile/Default/Extension State/LOCK
Normal file
2
my_profile/Default/Extension State/LOG
Normal file
2
my_profile/Default/Extension State/LOG
Normal file
@@ -0,0 +1,2 @@
|
||||
2026/05/02-16:17:44.775 3dfcfa Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension State since it was missing.
|
||||
2026/05/02-16:17:44.777 3dfcfa Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Extension State/MANIFEST-000001
|
||||
BIN
my_profile/Default/Extension State/MANIFEST-000001
Normal file
BIN
my_profile/Default/Extension State/MANIFEST-000001
Normal file
Binary file not shown.
BIN
my_profile/Default/Favicons
Normal file
BIN
my_profile/Default/Favicons
Normal file
Binary file not shown.
0
my_profile/Default/Favicons-journal
Normal file
0
my_profile/Default/Favicons-journal
Normal file
BIN
my_profile/Default/GPUCache/data_0
Normal file
BIN
my_profile/Default/GPUCache/data_0
Normal file
Binary file not shown.
BIN
my_profile/Default/GPUCache/data_1
Normal file
BIN
my_profile/Default/GPUCache/data_1
Normal file
Binary file not shown.
BIN
my_profile/Default/GPUCache/data_2
Normal file
BIN
my_profile/Default/GPUCache/data_2
Normal file
Binary file not shown.
BIN
my_profile/Default/GPUCache/data_3
Normal file
BIN
my_profile/Default/GPUCache/data_3
Normal file
Binary file not shown.
BIN
my_profile/Default/GPUCache/index
Normal file
BIN
my_profile/Default/GPUCache/index
Normal file
Binary file not shown.
BIN
my_profile/Default/History
Normal file
BIN
my_profile/Default/History
Normal file
Binary file not shown.
0
my_profile/Default/History-journal
Normal file
0
my_profile/Default/History-journal
Normal file
0
my_profile/Default/LOCK
Normal file
0
my_profile/Default/LOCK
Normal file
0
my_profile/Default/LOG
Normal file
0
my_profile/Default/LOG
Normal file
1
my_profile/Default/Local Storage/leveldb/CURRENT
Normal file
1
my_profile/Default/Local Storage/leveldb/CURRENT
Normal file
@@ -0,0 +1 @@
|
||||
MANIFEST-000001
|
||||
0
my_profile/Default/Local Storage/leveldb/LOCK
Normal file
0
my_profile/Default/Local Storage/leveldb/LOCK
Normal file
2
my_profile/Default/Local Storage/leveldb/LOG
Normal file
2
my_profile/Default/Local Storage/leveldb/LOG
Normal file
@@ -0,0 +1,2 @@
|
||||
2026/05/02-16:17:44.458 3dfd53 Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Local Storage/leveldb since it was missing.
|
||||
2026/05/02-16:17:44.462 3dfd53 Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Local Storage/leveldb/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