diff --git a/automation/__init__.py b/automation/__init__.py new file mode 100644 index 0000000..c0bfe84 --- /dev/null +++ b/automation/__init__.py @@ -0,0 +1,5 @@ +""" +automation/ — Playwright 기반 관리자 사이트 자동 입력 패키지 + +config/와 core/에 의존하며, 관리자 사이트의 UI 조작을 담당합니다. +""" diff --git a/automation/batter_input.py b/automation/batter_input.py new file mode 100644 index 0000000..a7ed4ba --- /dev/null +++ b/automation/batter_input.py @@ -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 + ' '; + } + + 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) diff --git a/automation/defense_popup.py b/automation/defense_popup.py new file mode 100644 index 0000000..9b2a4e1 --- /dev/null +++ b/automation/defense_popup.py @@ -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) diff --git a/automation/game_end_input.py b/automation/game_end_input.py new file mode 100644 index 0000000..360818b --- /dev/null +++ b/automation/game_end_input.py @@ -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) diff --git a/automation/lineup_input.py b/automation/lineup_input.py new file mode 100644 index 0000000..3f5471a --- /dev/null +++ b/automation/lineup_input.py @@ -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 diff --git a/automation/page_helpers.py b/automation/page_helpers.py new file mode 100644 index 0000000..269861b --- /dev/null +++ b/automation/page_helpers.py @@ -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 diff --git a/automation/pitch_input.py b/automation/pitch_input.py new file mode 100644 index 0000000..8bf5157 --- /dev/null +++ b/automation/pitch_input.py @@ -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)) diff --git a/automation/review_input.py b/automation/review_input.py new file mode 100644 index 0000000..11ce68b --- /dev/null +++ b/automation/review_input.py @@ -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 diff --git a/automation/runner_input.py b/automation/runner_input.py new file mode 100644 index 0000000..f0bd0e2 --- /dev/null +++ b/automation/runner_input.py @@ -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 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..41443a7 --- /dev/null +++ b/cli.py @@ -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()) diff --git a/commands/base.py b/commands/base.py new file mode 100644 index 0000000..9b5d6c3 --- /dev/null +++ b/commands/base.py @@ -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)") diff --git a/commands/record.py b/commands/record.py new file mode 100644 index 0000000..fa2f035 --- /dev/null +++ b/commands/record.py @@ -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 diff --git a/config/crawler_constants.yaml b/config/crawler_constants.yaml new file mode 100644 index 0000000..43ba7ea --- /dev/null +++ b/config/crawler_constants.yaml @@ -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 diff --git a/config/field_coordinates.yaml b/config/field_coordinates.yaml new file mode 100644 index 0000000..46347cb --- /dev/null +++ b/config/field_coordinates.yaml @@ -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] diff --git a/config/mappings.yaml b/config/mappings.yaml new file mode 100644 index 0000000..9695a34 --- /dev/null +++ b/config/mappings.yaml @@ -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] diff --git a/config/pitch_rules.yaml b/config/pitch_rules.yaml new file mode 100644 index 0000000..6f6ce32 --- /dev/null +++ b/config/pitch_rules.yaml @@ -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] diff --git a/config/review_rules.yaml b/config/review_rules.yaml new file mode 100644 index 0000000..8fd8d7d --- /dev/null +++ b/config/review_rules.yaml @@ -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: [인정, 불인정] diff --git a/config/site_selectors.yaml b/config/site_selectors.yaml new file mode 100644 index 0000000..cd6996c --- /dev/null +++ b/config/site_selectors.yaml @@ -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" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..609099f --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,6 @@ +""" +core/ — 순수 비즈니스 로직 패키지 + +Playwright, httpx 등 외부 I/O 의존성 없이 동작합니다. +모든 설정은 config/ YAML에서 config_loader를 통해 로드합니다. +""" diff --git a/core/change_parser.py b/core/change_parser.py new file mode 100644 index 0000000..93c0da0 --- /dev/null +++ b/core/change_parser.py @@ -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 diff --git a/core/config_loader.py b/core/config_loader.py new file mode 100644 index 0000000..d297198 --- /dev/null +++ b/core/config_loader.py @@ -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) diff --git a/core/field_calculator.py b/core/field_calculator.py new file mode 100644 index 0000000..d309fd0 --- /dev/null +++ b/core/field_calculator.py @@ -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) diff --git a/core/normalizer.py b/core/normalizer.py new file mode 100644 index 0000000..eb6db3e --- /dev/null +++ b/core/normalizer.py @@ -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" diff --git a/core/pitch_classifier.py b/core/pitch_classifier.py new file mode 100644 index 0000000..c39a19a --- /dev/null +++ b/core/pitch_classifier.py @@ -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() diff --git a/core/review_parser.py b/core/review_parser.py new file mode 100644 index 0000000..c081fe5 --- /dev/null +++ b/core/review_parser.py @@ -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) diff --git a/core/runner_classifier.py b/core/runner_classifier.py new file mode 100644 index 0000000..27bfcfd --- /dev/null +++ b/core/runner_classifier.py @@ -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 diff --git a/crawler/__init__.py b/crawler/__init__.py new file mode 100644 index 0000000..d941ac3 --- /dev/null +++ b/crawler/__init__.py @@ -0,0 +1,6 @@ +""" +crawler/ — 네이버 스포츠 API 크롤링 패키지 + +네이버 API에서 데이터를 수집하고, relay 데이터를 파싱하여 +정규화된 JSON 리포트를 생성합니다. +""" diff --git a/crawler/lineup_builder.py b/crawler/lineup_builder.py new file mode 100644 index 0000000..3e2a665 --- /dev/null +++ b/crawler/lineup_builder.py @@ -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"]), + } diff --git a/crawler/naver_api.py b/crawler/naver_api.py new file mode 100644 index 0000000..8b256f7 --- /dev/null +++ b/crawler/naver_api.py @@ -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 diff --git a/crawler/relay_parser.py b/crawler/relay_parser.py new file mode 100644 index 0000000..66b3dd6 --- /dev/null +++ b/crawler/relay_parser.py @@ -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) diff --git a/crawler/report_builder.py b/crawler/report_builder.py new file mode 100644 index 0000000..4de369b --- /dev/null +++ b/crawler/report_builder.py @@ -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 diff --git a/my_profile/BrowserMetrics/BrowserMetrics-69F5A518-63E9.pma b/my_profile/BrowserMetrics/BrowserMetrics-69F5A518-63E9.pma new file mode 100644 index 0000000..054df0c Binary files /dev/null and b/my_profile/BrowserMetrics/BrowserMetrics-69F5A518-63E9.pma differ diff --git a/my_profile/ChromeFeatureState b/my_profile/ChromeFeatureState new file mode 100644 index 0000000..07a76c9 --- /dev/null +++ b/my_profile/ChromeFeatureState @@ -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"} \ No newline at end of file diff --git a/my_profile/Default/Account Web Data b/my_profile/Default/Account Web Data new file mode 100644 index 0000000..1b2c37e Binary files /dev/null and b/my_profile/Default/Account Web Data differ diff --git a/my_profile/Default/Account Web Data-journal b/my_profile/Default/Account Web Data-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Affiliation Database b/my_profile/Default/Affiliation Database new file mode 100644 index 0000000..804a914 Binary files /dev/null and b/my_profile/Default/Affiliation Database differ diff --git a/my_profile/Default/Affiliation Database-journal b/my_profile/Default/Affiliation Database-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/BookmarkMergedSurfaceOrdering b/my_profile/Default/BookmarkMergedSurfaceOrdering new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/my_profile/Default/BookmarkMergedSurfaceOrdering @@ -0,0 +1,2 @@ +{ +} diff --git a/my_profile/Default/Cache/Cache_Data/13d9afa0c3ccf0e2_0 b/my_profile/Default/Cache/Cache_Data/13d9afa0c3ccf0e2_0 new file mode 100644 index 0000000..577d8b5 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/13d9afa0c3ccf0e2_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/20f8a7ac591e3cfb_0 b/my_profile/Default/Cache/Cache_Data/20f8a7ac591e3cfb_0 new file mode 100644 index 0000000..2818a70 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/20f8a7ac591e3cfb_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/36999aedce6b9850_0 b/my_profile/Default/Cache/Cache_Data/36999aedce6b9850_0 new file mode 100644 index 0000000..6cc965c Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/36999aedce6b9850_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/3fdc6fba64f26c1c_0 b/my_profile/Default/Cache/Cache_Data/3fdc6fba64f26c1c_0 new file mode 100644 index 0000000..87afd26 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/3fdc6fba64f26c1c_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/63f351f1930af849_0 b/my_profile/Default/Cache/Cache_Data/63f351f1930af849_0 new file mode 100644 index 0000000..71104ed Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/63f351f1930af849_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/66d04e225683fe99_0 b/my_profile/Default/Cache/Cache_Data/66d04e225683fe99_0 new file mode 100644 index 0000000..a70a165 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/66d04e225683fe99_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/7f62be85fe6088aa_0 b/my_profile/Default/Cache/Cache_Data/7f62be85fe6088aa_0 new file mode 100644 index 0000000..566e28c Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/7f62be85fe6088aa_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/a58e561b6854b4f4_0 b/my_profile/Default/Cache/Cache_Data/a58e561b6854b4f4_0 new file mode 100644 index 0000000..ed5fb7f Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/a58e561b6854b4f4_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/b4b76c79593d683b_0 b/my_profile/Default/Cache/Cache_Data/b4b76c79593d683b_0 new file mode 100644 index 0000000..d5909a1 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/b4b76c79593d683b_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/b56cc14f6e606ea0_0 b/my_profile/Default/Cache/Cache_Data/b56cc14f6e606ea0_0 new file mode 100644 index 0000000..605a6d9 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/b56cc14f6e606ea0_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/cd46246e2a12c82d_0 b/my_profile/Default/Cache/Cache_Data/cd46246e2a12c82d_0 new file mode 100644 index 0000000..8f4ca5e Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/cd46246e2a12c82d_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/f81e693f9ae23dc3_0 b/my_profile/Default/Cache/Cache_Data/f81e693f9ae23dc3_0 new file mode 100644 index 0000000..8c30a48 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/f81e693f9ae23dc3_0 differ diff --git a/my_profile/Default/Cache/Cache_Data/index b/my_profile/Default/Cache/Cache_Data/index new file mode 100644 index 0000000..79bd403 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/index differ diff --git a/my_profile/Default/Cache/Cache_Data/index-dir/the-real-index b/my_profile/Default/Cache/Cache_Data/index-dir/the-real-index new file mode 100644 index 0000000..6c3ba39 Binary files /dev/null and b/my_profile/Default/Cache/Cache_Data/index-dir/the-real-index differ diff --git a/my_profile/Default/Cache/No_Vary_Search/journal.baj b/my_profile/Default/Cache/No_Vary_Search/journal.baj new file mode 100644 index 0000000..54fe66e --- /dev/null +++ b/my_profile/Default/Cache/No_Vary_Search/journal.baj @@ -0,0 +1 @@ +$F~ \ No newline at end of file diff --git a/my_profile/Default/Cache/No_Vary_Search/snapshot.baf b/my_profile/Default/Cache/No_Vary_Search/snapshot.baf new file mode 100644 index 0000000..8912405 Binary files /dev/null and b/my_profile/Default/Cache/No_Vary_Search/snapshot.baf differ diff --git a/my_profile/Default/ClientCertificates/LOCK b/my_profile/Default/ClientCertificates/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/ClientCertificates/LOG b/my_profile/Default/ClientCertificates/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Code Cache/js/16508a754b416371_0 b/my_profile/Default/Code Cache/js/16508a754b416371_0 new file mode 100644 index 0000000..2307279 Binary files /dev/null and b/my_profile/Default/Code Cache/js/16508a754b416371_0 differ diff --git a/my_profile/Default/Code Cache/js/749d48cfaad904d2_0 b/my_profile/Default/Code Cache/js/749d48cfaad904d2_0 new file mode 100644 index 0000000..c7b7a92 Binary files /dev/null and b/my_profile/Default/Code Cache/js/749d48cfaad904d2_0 differ diff --git a/my_profile/Default/Code Cache/js/index b/my_profile/Default/Code Cache/js/index new file mode 100644 index 0000000..79bd403 Binary files /dev/null and b/my_profile/Default/Code Cache/js/index differ diff --git a/my_profile/Default/Code Cache/js/index-dir/the-real-index b/my_profile/Default/Code Cache/js/index-dir/the-real-index new file mode 100644 index 0000000..e69e5e0 Binary files /dev/null and b/my_profile/Default/Code Cache/js/index-dir/the-real-index differ diff --git a/my_profile/Default/Code Cache/wasm/index b/my_profile/Default/Code Cache/wasm/index new file mode 100644 index 0000000..79bd403 Binary files /dev/null and b/my_profile/Default/Code Cache/wasm/index differ diff --git a/my_profile/Default/Code Cache/wasm/index-dir/the-real-index b/my_profile/Default/Code Cache/wasm/index-dir/the-real-index new file mode 100644 index 0000000..fab9652 Binary files /dev/null and b/my_profile/Default/Code Cache/wasm/index-dir/the-real-index differ diff --git a/my_profile/Default/Cookies b/my_profile/Default/Cookies new file mode 100644 index 0000000..923fcfc Binary files /dev/null and b/my_profile/Default/Cookies differ diff --git a/my_profile/Default/Cookies-journal b/my_profile/Default/Cookies-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/DawnGraphiteCache/data_0 b/my_profile/Default/DawnGraphiteCache/data_0 new file mode 100644 index 0000000..d76fb77 Binary files /dev/null and b/my_profile/Default/DawnGraphiteCache/data_0 differ diff --git a/my_profile/Default/DawnGraphiteCache/data_1 b/my_profile/Default/DawnGraphiteCache/data_1 new file mode 100644 index 0000000..dcaafa9 Binary files /dev/null and b/my_profile/Default/DawnGraphiteCache/data_1 differ diff --git a/my_profile/Default/DawnGraphiteCache/data_2 b/my_profile/Default/DawnGraphiteCache/data_2 new file mode 100644 index 0000000..c7e2eb9 Binary files /dev/null and b/my_profile/Default/DawnGraphiteCache/data_2 differ diff --git a/my_profile/Default/DawnGraphiteCache/data_3 b/my_profile/Default/DawnGraphiteCache/data_3 new file mode 100644 index 0000000..5eec973 Binary files /dev/null and b/my_profile/Default/DawnGraphiteCache/data_3 differ diff --git a/my_profile/Default/DawnGraphiteCache/index b/my_profile/Default/DawnGraphiteCache/index new file mode 100644 index 0000000..f72ccc1 Binary files /dev/null and b/my_profile/Default/DawnGraphiteCache/index differ diff --git a/my_profile/Default/DawnWebGPUCache/data_0 b/my_profile/Default/DawnWebGPUCache/data_0 new file mode 100644 index 0000000..d76fb77 Binary files /dev/null and b/my_profile/Default/DawnWebGPUCache/data_0 differ diff --git a/my_profile/Default/DawnWebGPUCache/data_1 b/my_profile/Default/DawnWebGPUCache/data_1 new file mode 100644 index 0000000..dcaafa9 Binary files /dev/null and b/my_profile/Default/DawnWebGPUCache/data_1 differ diff --git a/my_profile/Default/DawnWebGPUCache/data_2 b/my_profile/Default/DawnWebGPUCache/data_2 new file mode 100644 index 0000000..c7e2eb9 Binary files /dev/null and b/my_profile/Default/DawnWebGPUCache/data_2 differ diff --git a/my_profile/Default/DawnWebGPUCache/data_3 b/my_profile/Default/DawnWebGPUCache/data_3 new file mode 100644 index 0000000..5eec973 Binary files /dev/null and b/my_profile/Default/DawnWebGPUCache/data_3 differ diff --git a/my_profile/Default/DawnWebGPUCache/index b/my_profile/Default/DawnWebGPUCache/index new file mode 100644 index 0000000..cdc389e Binary files /dev/null and b/my_profile/Default/DawnWebGPUCache/index differ diff --git a/my_profile/Default/Extension Rules/CURRENT b/my_profile/Default/Extension Rules/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Extension Rules/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Extension Rules/LOCK b/my_profile/Default/Extension Rules/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Extension Rules/LOG b/my_profile/Default/Extension Rules/LOG new file mode 100644 index 0000000..3fb08a0 --- /dev/null +++ b/my_profile/Default/Extension Rules/LOG @@ -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 diff --git a/my_profile/Default/Extension Rules/MANIFEST-000001 b/my_profile/Default/Extension Rules/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Extension Rules/MANIFEST-000001 differ diff --git a/my_profile/Default/Extension Scripts/CURRENT b/my_profile/Default/Extension Scripts/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Extension Scripts/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Extension Scripts/LOCK b/my_profile/Default/Extension Scripts/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Extension Scripts/LOG b/my_profile/Default/Extension Scripts/LOG new file mode 100644 index 0000000..966884c --- /dev/null +++ b/my_profile/Default/Extension Scripts/LOG @@ -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 diff --git a/my_profile/Default/Extension Scripts/MANIFEST-000001 b/my_profile/Default/Extension Scripts/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Extension Scripts/MANIFEST-000001 differ diff --git a/my_profile/Default/Extension State/CURRENT b/my_profile/Default/Extension State/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Extension State/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Extension State/LOCK b/my_profile/Default/Extension State/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Extension State/LOG b/my_profile/Default/Extension State/LOG new file mode 100644 index 0000000..c20e066 --- /dev/null +++ b/my_profile/Default/Extension State/LOG @@ -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 diff --git a/my_profile/Default/Extension State/MANIFEST-000001 b/my_profile/Default/Extension State/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Extension State/MANIFEST-000001 differ diff --git a/my_profile/Default/Favicons b/my_profile/Default/Favicons new file mode 100644 index 0000000..4856bc2 Binary files /dev/null and b/my_profile/Default/Favicons differ diff --git a/my_profile/Default/Favicons-journal b/my_profile/Default/Favicons-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/GPUCache/data_0 b/my_profile/Default/GPUCache/data_0 new file mode 100644 index 0000000..d76fb77 Binary files /dev/null and b/my_profile/Default/GPUCache/data_0 differ diff --git a/my_profile/Default/GPUCache/data_1 b/my_profile/Default/GPUCache/data_1 new file mode 100644 index 0000000..dcaafa9 Binary files /dev/null and b/my_profile/Default/GPUCache/data_1 differ diff --git a/my_profile/Default/GPUCache/data_2 b/my_profile/Default/GPUCache/data_2 new file mode 100644 index 0000000..c7e2eb9 Binary files /dev/null and b/my_profile/Default/GPUCache/data_2 differ diff --git a/my_profile/Default/GPUCache/data_3 b/my_profile/Default/GPUCache/data_3 new file mode 100644 index 0000000..5eec973 Binary files /dev/null and b/my_profile/Default/GPUCache/data_3 differ diff --git a/my_profile/Default/GPUCache/index b/my_profile/Default/GPUCache/index new file mode 100644 index 0000000..dde1bbe Binary files /dev/null and b/my_profile/Default/GPUCache/index differ diff --git a/my_profile/Default/History b/my_profile/Default/History new file mode 100644 index 0000000..64bd16d Binary files /dev/null and b/my_profile/Default/History differ diff --git a/my_profile/Default/History-journal b/my_profile/Default/History-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/LOCK b/my_profile/Default/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/LOG b/my_profile/Default/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Local Storage/leveldb/CURRENT b/my_profile/Default/Local Storage/leveldb/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Local Storage/leveldb/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Local Storage/leveldb/LOCK b/my_profile/Default/Local Storage/leveldb/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Local Storage/leveldb/LOG b/my_profile/Default/Local Storage/leveldb/LOG new file mode 100644 index 0000000..42c39f1 --- /dev/null +++ b/my_profile/Default/Local Storage/leveldb/LOG @@ -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 diff --git a/my_profile/Default/Local Storage/leveldb/MANIFEST-000001 b/my_profile/Default/Local Storage/leveldb/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Local Storage/leveldb/MANIFEST-000001 differ diff --git a/my_profile/Default/Login Data b/my_profile/Default/Login Data new file mode 100644 index 0000000..d3dce2e Binary files /dev/null and b/my_profile/Default/Login Data differ diff --git a/my_profile/Default/Login Data For Account b/my_profile/Default/Login Data For Account new file mode 100644 index 0000000..d3dce2e Binary files /dev/null and b/my_profile/Default/Login Data For Account differ diff --git a/my_profile/Default/Login Data For Account-journal b/my_profile/Default/Login Data For Account-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Login Data-journal b/my_profile/Default/Login Data-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Network Persistent State b/my_profile/Default/Network Persistent State new file mode 100644 index 0000000..4cee1c1 --- /dev/null +++ b/my_profile/Default/Network Persistent State @@ -0,0 +1 @@ +{"net":{"http_server_properties":{"servers":[{"alternative_service":[{"advertised_alpns":["h3"],"expiration":"13424771864918178","port":443,"protocol_str":"quic"}],"anonymization":["GAAAABIAAABodHRwczovL2dvb2dsZS5jb20AAA==",false,0],"server":"https://accounts.google.com","supports_spdy":true},{"anonymization":["HAAAABUAAABodHRwOi8vNTguMjI5LjI1My4xNjgAAAA=",false,0],"server":"https://content-autofill.googleapis.com","supports_spdy":true},{"alternative_service":[{"advertised_alpns":["h3"],"expiration":"13424771864920454","port":443,"protocol_str":"quic"}],"anonymization":["GAAAABIAAABodHRwczovL2dvb2dsZS5jb20AAA==",false,0],"server":"https://www.google.com"}],"supports_quic":{"address":"192.168.1.25","used_quic":true},"version":5},"network_qualities":{"CAISABiAgICA+P////8B":"4G"}}} \ No newline at end of file diff --git a/my_profile/Default/PersistentOriginTrials/LOCK b/my_profile/Default/PersistentOriginTrials/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/PersistentOriginTrials/LOG b/my_profile/Default/PersistentOriginTrials/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Preferences b/my_profile/Default/Preferences new file mode 100644 index 0000000..366ebb8 --- /dev/null +++ b/my_profile/Default/Preferences @@ -0,0 +1 @@ +{"accessibility":{"captions":{"headless_caption_enabled":false}},"account_tracker_service_last_update":"13422179864772836","ack_existing_ntp_extensions":true,"aim_eligibility_service":{"aim_eligibility_response":"CAAQARgAIAAwAToAQg0KCQoDdWRtEgI1MBABSAFQAA=="},"apps":{"shortcuts_arch":"arm64","shortcuts_version":8},"autocomplete":{"retention_policy_last_version":147},"autofill":{"last_version_deduped":147},"bookmark":{"storage_computation_last_update":"13422179864743793"},"browser":{"window_placement":{"bottom":987,"left":22,"maximized":false,"right":1304,"top":61,"work_area_bottom":1107,"work_area_left":0,"work_area_right":1710,"work_area_top":39}},"commerce_daily_metrics_last_update_time":"13422179864743986","countryid_at_install":19282,"domain_diversity":{"last_reporting_timestamp":"13422179864744050","last_reporting_timestamp_v4":"13422179864744056"},"enterprise_profile_guid":"7c73be48-68b9-4486-985d-539b22981979","extensions":{"alerts":{"initialized":true},"chrome_url_overrides":{},"last_chrome_version":"147.0.7727.138"},"gaia_cookie":{"changed_time":1777706264.92036,"hash":"2jmj7l5rSw0yVb/vlWAYkK/YBwk=","last_list_accounts_binary_data":"","periodic_report_time_2":"13422179864428572"},"gcm":{"product_category_for_subtypes":"com.chrome.macosx"},"google":{"services":{"signin_scoped_device_id":"be43ddde-7b98-43c2-b661-fc1d2354ca5a"}},"in_product_help":{"recent_session_enabled_time":"13422179864440867","recent_session_start_times":["13422179864440867"],"session_last_active_time":"13422179864440867","session_number":2,"session_start_time":"13422179864440867"},"intl":{"selected_languages":"ko-KR,ko,en-US,en"},"invalidation":{"per_sender_registered_for_invalidation":{"1013309121859":{},"947318989803":{}}},"media":{"engagement":{"schema_version":5}},"migrated_user_scripts_toggle":true,"ntp":{"num_personal_suggestions":1},"privacy_sandbox":{"first_party_sets_data_access_allowed_initialized":true},"profile":{"avatar_index":26,"background_password_check":{"check_fri_weight":9,"check_interval":"2592000000000","check_mon_weight":6,"check_sat_weight":6,"check_sun_weight":6,"check_thu_weight":9,"check_tue_weight":9,"check_wed_weight":9,"next_check_time":"13424205683503406"},"content_settings":{"exceptions":{"3pcd_heuristics_grants":{},"abusive_notification_permissions":{},"access_to_get_all_screens_media_in_session":{},"anti_abuse":{},"app_banner":{},"ar":{},"are_suspicious_notifications_allowlisted_by_user":{},"auto_picture_in_picture":{},"auto_select_certificate":{},"automatic_downloads":{},"automatic_fullscreen":{},"autoplay":{},"background_sync":{},"bluetooth_chooser_data":{},"bluetooth_guard":{},"bluetooth_scanning":{},"camera_pan_tilt_zoom":{},"captured_surface_control":{},"client_hints":{},"clipboard":{},"controlled_frame":{},"cookie_controls_metadata":{"http://58.229.253.168,*":{"last_modified":"13422179864848984","setting":{}}},"cookies":{},"direct_sockets":{},"direct_sockets_private_network_access":{},"display_media_system_audio":{},"disruptive_notification_permissions":{},"durable_storage":{},"fedcm_idp_registration":{},"fedcm_idp_signin":{"https://accounts.google.com:443,*":{"last_modified":"13422179864920464","setting":{"chosen-objects":[{"idp-origin":"https://accounts.google.com","idp-signin-status":false}]}}},"fedcm_share":{},"file_system_access_chooser_data":{},"file_system_access_extended_permission":{},"file_system_access_restore_permission":{},"file_system_last_picked_directory":{},"file_system_read_guard":{},"file_system_write_guard":{},"formfill_metadata":{},"geolocation":{},"geolocation_with_options":{},"hand_tracking":{},"has_migrated_local_network_access":true,"hid_chooser_data":{},"hid_guard":{},"http_allowed":{},"https_enforced":{},"idle_detection":{},"images":{},"important_site_info":{},"initialized_translations":{},"intent_picker_auto_display":{},"javascript":{},"javascript_jit":{},"javascript_optimizer":{},"keyboard_lock":{},"legacy_cookie_access":{},"legacy_cookie_scope":{},"local_fonts":{},"local_network":{},"local_network_access":{},"loopback_network":{},"media_engagement":{"http://58.229.253.168:8089,*":{"expiration":"13429955865114076","last_modified":"13422179865114079","lifetime":"7776000000000","setting":{"hasHighScore":false,"lastMediaPlaybackTime":0.0,"mediaPlaybacks":0,"visits":1}}},"media_stream_camera":{},"media_stream_mic":{},"midi_sysex":{},"mixed_script":{},"nfc_devices":{},"notification_interactions":{},"notification_permission_review":{},"notifications":{},"ondevice_languages_downloaded":{},"password_protection":{},"payment_handler":{},"permission_actions_history":{},"permission_autoblocking_data":{},"permission_autorevocation_data":{},"pointer_lock":{},"popups":{},"protocol_handler":{},"reduced_accept_language":{},"safe_browsing_url_check_data":{},"sensors":{},"serial_chooser_data":{},"serial_guard":{},"site_engagement":{"http://58.229.253.168:8089,*":{"last_modified":"13422179864849563","setting":{"lastEngagementTime":1.342217986484956e+16,"lastShortcutLaunchTime":0.0,"pointsAddedToday":3.0,"rawScore":3.0}}},"sound":{},"speaker_selection":{},"ssl_cert_decisions":{},"storage_access":{},"storage_access_header_origin_trial":{},"subresource_filter":{},"subresource_filter_data":{},"suspicious_notification_ids":{},"suspicious_notification_show_original":{},"top_level_storage_access":{},"unused_site_permissions":{},"usb_chooser_data":{},"usb_guard":{},"vr":{},"web_app_installation":{},"webid_api":{},"webid_auto_reauthn":{},"window_placement":{}},"pref_version":1},"created_by_version":"147.0.7727.138","creation_time":"13422179864411737","default_content_setting_values":{"has_migrated_local_network_access":true},"exit_type":"Normal","family_member_role":"not_in_family","last_engagement_time":"13422179864849560","managed":{"locally_parent_approved_extensions":{},"locally_parent_approved_extensions_migration_state":1},"managed_user_id":"","name":"내 Chrome","password_hash_data_list":[]},"safebrowsing":{"event_timestamps":{},"hash_real_time_ohttp_expiration_time":"13422439064916180","hash_real_time_ohttp_key":"jwAgUE/FSD45Gveia3lp63pixKo4jL/S7JwT/TTyD9GicTAABAABAAI=","hash_real_time_ohttp_key_fetch_url":"https://safebrowsingohttpgateway.googleapis.com/v1/ohttp/hpkekeyconfig","metrics_last_log_time":"13422179864","scout_reporting_enabled_when_deprecated":false},"safety_hub":{"unused_site_permissions_revocation":{"migration_completed":true}},"saved_tab_groups":{"did_enable_shared_tab_groups_in_last_session":false,"specifics_to_data_migration":true},"segmentation_platform":{"uma_in_sql_start_time":"13422179864438809"},"sessions":{"event_log":[{"crashed":false,"time":"13422179864429124","type":0},{"did_schedule_command":true,"first_session_service":true,"tab_count":1,"time":"13422179865101718","type":2,"window_count":1}],"session_data_status":5},"settings":{"force_google_safesearch":false},"signin":{"accounts_metadata_dict":{},"allowed":true},"site_search_settings":{"overridden_keywords":[]},"syncing_theme_prefs_migrated_to_non_syncing":true,"tab_search":{"pinned_to_tabstrip":false,"pinned_to_tabstrip_migration_complete":true},"toolbar":{"pinned_cast_migration_complete":true,"pinned_chrome_labs_migration_complete":true},"translate_site_blacklist":[],"translate_site_blocklist_with_time":{},"webauthn":{"touchid":{"metadata_secret":"LLX6MGZpqLvEIWTdotDB+o6zkfGSHie2pPbwg1LbECo="}}} \ No newline at end of file diff --git a/my_profile/Default/README b/my_profile/Default/README new file mode 100644 index 0000000..98d9d27 --- /dev/null +++ b/my_profile/Default/README @@ -0,0 +1 @@ +Google Chrome settings and storage represent user-selected preferences and information and MUST not be extracted, overwritten or modified except through Google Chrome defined APIs. \ No newline at end of file diff --git a/my_profile/Default/Reporting and NEL b/my_profile/Default/Reporting and NEL new file mode 100644 index 0000000..58ef322 Binary files /dev/null and b/my_profile/Default/Reporting and NEL differ diff --git a/my_profile/Default/Reporting and NEL-journal b/my_profile/Default/Reporting and NEL-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Safe Browsing Cookies b/my_profile/Default/Safe Browsing Cookies new file mode 100644 index 0000000..f6ddd18 Binary files /dev/null and b/my_profile/Default/Safe Browsing Cookies differ diff --git a/my_profile/Default/Safe Browsing Cookies-journal b/my_profile/Default/Safe Browsing Cookies-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Secure Preferences b/my_profile/Default/Secure Preferences new file mode 100644 index 0000000..46dc9a4 --- /dev/null +++ b/my_profile/Default/Secure Preferences @@ -0,0 +1 @@ +{"extensions":{"settings":{"ahfgeienlihckogmohjhadlkjgocpleb":{"account_extension_type":0,"active_permissions":{"api":["management","system.display","system.storage","webstorePrivate","system.cpu","system.memory","system.network"],"explicit_host":[],"manifest_permissions":[],"scriptable_host":[]},"app_launcher_ordinal":"t","commands":{},"content_settings":[],"creation_flags":1,"disable_reasons":[],"events":[],"first_install_time":"13422179864439924","from_webstore":false,"incognito_content_settings":[],"incognito_preferences":{},"last_update_time":"13422179864439924","location":5,"manifest":{"app":{"launch":{"web_url":"https://chrome.google.com/webstore"},"urls":["https://chrome.google.com/webstore"]},"description":"Chrome에 사용할 유용한 애플리케이션, 게임, 확장 프로그램 및 테마를 찾아보세요.","icons":{"128":"webstore_icon_128.png","16":"webstore_icon_16.png"},"key":"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtl3tO0osjuzRsf6xtD2SKxPlTfuoy7AWoObysitBPvH5fE1NaAA1/2JkPWkVDhdLBWLaIBPYeXbzlHp3y4Vv/4XG+aN5qFE3z+1RU/NqkzVYHtIpVScf3DjTYtKVL66mzVGijSoAIwbFCC3LpGdaoe6Q1rSRDp76wR6jjFzsYwQIDAQAB","name":"웹 스토어","permissions":["webstorePrivate","management","system.cpu","system.display","system.memory","system.network","system.storage"],"version":"0.2"},"needs_sync":true,"page_ordinal":"n","path":"/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/147.0.7727.138/Resources/web_store","preferences":{},"regular_only_preferences":{},"was_installed_by_default":false,"was_installed_by_oem":false},"mhjfbmdgcfjbbpaeojofohoefgiehjai":{"account_extension_type":0,"active_permissions":{"api":["contentSettings","fileSystem","fileSystem.write","metricsPrivate","tabs","resourcesPrivate","pdfViewerPrivate"],"explicit_host":["chrome://resources/*","chrome://webui-test/*"],"manifest_permissions":[],"scriptable_host":[]},"commands":{},"content_settings":[],"creation_flags":1,"disable_reasons":[],"events":[],"first_install_time":"13422179864440120","from_webstore":false,"incognito_content_settings":[],"incognito_preferences":{},"last_update_time":"13422179864440120","location":5,"manifest":{"content_security_policy":"script-src 'self' blob: filesystem: chrome://resources chrome://webui-test; object-src * blob: externalfile: file: filesystem: data:","description":"","incognito":"split","key":"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDN6hM0rsDYGbzQPQfOygqlRtQgKUXMfnSjhIBL7LnReAVBEd7ZmKtyN2qmSasMl4HZpMhVe2rPWVVwBDl6iyNE/Kok6E6v6V3vCLGsOpQAuuNVye/3QxzIldzG/jQAdWZiyXReRVapOhZtLjGfywCvlWq7Sl/e3sbc0vWybSDI2QIDAQAB","manifest_version":2,"mime_types":["application/pdf"],"mime_types_handler":"index.html","name":"Chrome PDF Viewer","offline_enabled":true,"permissions":["chrome://resources/","chrome://webui-test/","contentSettings","metricsPrivate","pdfViewerPrivate","resourcesPrivate","tabs",{"fileSystem":["write"]}],"version":"1","web_accessible_resources":["pdf_embedder.css"]},"path":"/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/147.0.7727.138/Resources/pdf","preferences":{},"regular_only_preferences":{},"was_installed_by_default":false,"was_installed_by_oem":false}}},"pinned_tabs":[],"protection":{"macs":{"account_values":{"browser":{"show_home_button":"87A65DB60AD565CEA2E4D70568F94494E0D7DD959850BBE4DFF71090E0E01E41","show_home_button_encrypted_hash":"djEwLAFpEE60Pvtep5i5VwB9KIE/D7IWWW/N476TfvEW2FFANErVTfsI0ICOXL3ZeVcz"},"extensions":{"ui":{"developer_mode":"9B2A0668C1F5A2F07972C89E5CFC960287C6D732B33DDFBA864AA145A304D0BD","developer_mode_encrypted_hash":"djEwjFkItsqsxxwA63TSDAArqVqU9RmbMYENKVg7M4rilHglOmuUUum0HXBuI/8VCayw"}},"homepage":"3F02EEF94984F7AD10F24A563AF03427B8C902126E18F90B40C971C614EF7772","homepage_encrypted_hash":"djEwcnYzJETBAMdpFoCvEeYYdzhy1hnaf9tRGLR2YkrP0BzM0kWLrWyhtfShBvm0Vh/r","homepage_is_newtabpage":"2A9E85EBA71203897C15E00EEB79D1C43A1F7ADBC934525AAB8E288AFA1C18AB","homepage_is_newtabpage_encrypted_hash":"djEwD901VYadGuNLtLVAbIPy8KwpnFztmXX306ukDPHNEHnkDOuWI/skFYQadjN3smCy","session":{"restore_on_startup":"17BBA523101854221167E295E30D73A4FAFFA9BEA1B1695DB660BDF892046CB1","restore_on_startup_encrypted_hash":"djEw3gzrljylNRf9m76bsLqkuD5WVv3l0Wf1c71pvWUPv3lww9z+wuYt2UurUgW5Qztz","startup_urls":"2B21504079D835081D3AE2ACB9791BDFCF0698A7AD03A555A7B3CD824DE8DB9A","startup_urls_encrypted_hash":"djEw4PATGWvOCxbnQyIqg1tezINyad+xyoDDWmoYcEZ5cHCgx1XW3tR8WW2OPLmB031d"}},"browser":{"show_home_button":"9F3A842D32991EF2D2FBFA9F86C297B8C46CB2FB8B499699A8FAAF3F6A42F4D3","show_home_button_encrypted_hash":"djEwBkiY2KCKpOz/TegjULtkbsWGLOmmhkJGDE7gyt8vL5oZsf6RlHhr+0jAV/W7wKg4"},"default_search_provider_data":{"template_url_data":"092E97C43C6166DF60E01F13CB2EA358DB0A233C5CD96062EAD350963867489F","template_url_data_encrypted_hash":"djEwPP5gs1VBj4hrk2r+zGTrpmYT6jFW+TbzQGQyoNNfUMB2oTOx/3Vx9MZIWUXr0sEM"},"enterprise_signin":{"policy_recovery_token":"5E1C7D9053B5EA29A3E25F44EE2A5A8B871AC6621E9B1B21EB7EF26D1B5B28DB","policy_recovery_token_encrypted_hash":"djEwIQWH8rh5lSCIXOTu++fh9nmTV5GjJB9RxZbmO/RJv1HcoAls9E0WDJW9WpLpuMh0"},"extensions":{"install":{"initiallist":"B6CC7CC367ADFC66AAC5E4233ED98D2FDAF45861EC497973EB710340037C1D81","initiallist_encrypted_hash":"djEwcGm7SYzYkCa4UHuKn66BLcObsamWP01IXoP5eCtzeZO/1yCrgOZwVgNmwWdPNJDS","initialprovidername":"1A1087F39E8011AE3976326B69F274D814B81D4DE6AF7C005392E3873BE52D54","initialprovidername_encrypted_hash":"djEwFBQWZF8KYKOvCdPLnzRvVXZWg1mA3foOgoNMxBcrZsq7OldhC7QGI+Oyho59r3vW"},"settings":{"ahfgeienlihckogmohjhadlkjgocpleb":"D7D87B5B4C6784FB5B35C4E9E7EC6EFCFF7FC458716D93D58B0FF82AFC8C208A","mhjfbmdgcfjbbpaeojofohoefgiehjai":"3819C0E07E5612EFA793EFA1F2D7E2946BCDFEBFE50F16F10A256C9B5B1BD863"},"settings_encrypted_hash":{"ahfgeienlihckogmohjhadlkjgocpleb":"djEwoX0IzyBS3zBMaEFOkNGJnubkkmzNVn3kCUv3Peum8ef2bckxZGyQGjTHjvDO6v+W","mhjfbmdgcfjbbpaeojofohoefgiehjai":"djEwPS0UStxOiYF855qHeTdl+TT+3pTPhxAgKfMreyXacxorN+nsyxs5m7H8YCOzvTx/"},"ui":{"developer_mode":"86DCB1726CC047042CFFA997967048E5371C542D783729E7C65248D0FDC75503","developer_mode_encrypted_hash":"djEwgR/aCjKrq7sqRQU30tFxbG4yfFNUvxBka/dpxQ6nr5YIE1/Jwao7seW89qtGliBS"}},"google":{"services":{"account_id":"F0DE1CDDA2F84735617EBF8B9E12EC5BBC6D55FE55EA63D289F81439142F0649","account_id_encrypted_hash":"djEw1yqg+kXI/v/s4JLum1QNCAl6pEiFKD7+E4lcIcWhYk5ORlpqndPOTl/rXDywqaz4","last_signed_in_username":"03C182E37B0CD5E3DD489D13C876CB8674BBF1442727FB4C2CBD08A2D78C7EE6","last_signed_in_username_encrypted_hash":"djEwRVQY/MfLnYTfcDJtCQYJoDvc8+/h5yyvVpm0/jP3LYiRe71EyjzXI63FVgV3LLpj","last_username":"6FA89ADBB033F2CC294F59E0E5E50A17B05355A709A3137D933CF19F7F1D20E7","last_username_encrypted_hash":"djEwRnyLytu2vS6S3GXtxFDqMiaaZVlyfwPPVmCvbcMIDCyJNCzsWGSpi87qR6qQBRSb"}},"homepage":"2E484B8C63F82FF65762F23E791E1F035F1C74522694594A371AF8E1798407A4","homepage_encrypted_hash":"djEwAjx6GbjhF0CLflIfeoW1TCqM/f1VAWHh7jAs2A75KtVujLyMZrqPF9diXnK2VjH5","homepage_is_newtabpage":"77127D7C7430650A52EA85DDA1428FB4B5D823AB5FD60417B86FEEC591375653","homepage_is_newtabpage_encrypted_hash":"djEwjXn9cWkINVprEPKSs0WwHw8IfMj30DxX6cxT0oL85jX0BQH0t4dZLrGBiTfWG10P","media":{"storage_id_salt":"CDBCF5FB45E1D29C078537CB37EE2CFCACA31954251B2C80381968A3F8FF8A77","storage_id_salt_encrypted_hash":"djEwtLvIpUrzwYQh6sW7FR7i4xkS9X/AwR9sW83bo3hM77NTnShfku4eSgX2vAFNLFyx"},"pinned_tabs":"5FC03CA0CC6D32FBD8192C02AB974067011065B55056AC2BBAF4D7FFF7FD8620","pinned_tabs_encrypted_hash":"djEwWdnZlcTtSna9dCM5AxAQPuuKcm5BF6q3mahAuFuei0pkKTNaRUC+odtSSXPAeDb/","prefs":{"preference_reset_time":"073A992F516784166C7C1BB359A21E5CCBEF3E555B8021D8EC306A2FDA6A407E","preference_reset_time_encrypted_hash":"djEwlUyp8Q1+n6S5D4comK3KWgidQ+F8anTp15uZ92NI5DYIESbo+8eWhBrrEtgrXVPq"},"safebrowsing":{"incidents_sent":"DABC1CB9F1B9C7AF9092A71B4D7F6674A0B41DBA4A604FB24BA7A8E395E1959B","incidents_sent_encrypted_hash":"djEwwE5iFLzDAQ+7NI6rCQLgxs5G7StuoSNX4BJeHtYrOsVzznGVLi+dveq7gyUC6jSr"},"schedule_to_flush_to_disk":"CA6E264D5917CD4321BF40A78520353AEDCBBC6542CDF7B65730B90544A710C5","schedule_to_flush_to_disk_encrypted_hash":"djEwbkxOqwwnnOwFnOuFxNQrzk99GAd93q7beSPD291PWbyOvF4BJ0UNInmm+XZoVwEt","search_provider_overrides":"044C6240B6BF15C218B7920D7EBCBCA84E807E2CB524153250DDC11240711D55","search_provider_overrides_encrypted_hash":"djEwsM8/e4hHimrEuNKYjER3rwb1zCmyeJZ/QWc1NRWq2RUvNhmpqkfloaHGDak0nE/x","session":{"restore_on_startup":"0321D2FBB10ED0E4E6EAFD9DB17E7C38C9246B075F7F9107690785F9D78DE464","restore_on_startup_encrypted_hash":"djEwhR1B9k+5eBExOBAgR1lsQlm4gWV3bUwMwrfCtBOptXhVAvo2Xlmc800njtGZWoU9","startup_urls":"2EDF192235BC669E77E9C2FA0BD1D06277E8D750246E7966B105F2D0F8717E23","startup_urls_encrypted_hash":"djEw4XVkPESnRtNHPsaKq4mkPHtB4Qox+ipNf7AkO5OEcgUXAC8tHRXDH4BIwN4xXVlk"}},"super_mac":"96EE37231CB446F83CAA453B908637A14F79531577BED266E36A18DFF9F12BEE"},"schedule_to_flush_to_disk":"13422179864745570"} \ No newline at end of file diff --git a/my_profile/Default/Segmentation Platform/SegmentInfoDB/LOCK b/my_profile/Default/Segmentation Platform/SegmentInfoDB/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Segmentation Platform/SegmentInfoDB/LOG b/my_profile/Default/Segmentation Platform/SegmentInfoDB/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Segmentation Platform/SignalDB/LOCK b/my_profile/Default/Segmentation Platform/SignalDB/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Segmentation Platform/SignalDB/LOG b/my_profile/Default/Segmentation Platform/SignalDB/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Segmentation Platform/SignalStorageConfigDB/LOCK b/my_profile/Default/Segmentation Platform/SignalStorageConfigDB/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Segmentation Platform/SignalStorageConfigDB/LOG b/my_profile/Default/Segmentation Platform/SignalStorageConfigDB/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/ServerCertificate b/my_profile/Default/ServerCertificate new file mode 100644 index 0000000..0474c03 Binary files /dev/null and b/my_profile/Default/ServerCertificate differ diff --git a/my_profile/Default/ServerCertificate-journal b/my_profile/Default/ServerCertificate-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Session Storage/CURRENT b/my_profile/Default/Session Storage/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Session Storage/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Session Storage/LOCK b/my_profile/Default/Session Storage/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Session Storage/LOG b/my_profile/Default/Session Storage/LOG new file mode 100644 index 0000000..d3fd3a1 --- /dev/null +++ b/my_profile/Default/Session Storage/LOG @@ -0,0 +1,2 @@ +2026/05/02-16:17:44.513 3dfd53 Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Session Storage since it was missing. +2026/05/02-16:17:44.516 3dfd53 Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Session Storage/MANIFEST-000001 diff --git a/my_profile/Default/Session Storage/MANIFEST-000001 b/my_profile/Default/Session Storage/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Session Storage/MANIFEST-000001 differ diff --git a/my_profile/Default/Sessions/Session_13422179865101812 b/my_profile/Default/Sessions/Session_13422179865101812 new file mode 100644 index 0000000..bcb10aa Binary files /dev/null and b/my_profile/Default/Sessions/Session_13422179865101812 differ diff --git a/my_profile/Default/Sessions/Tabs_13422179865124776 b/my_profile/Default/Sessions/Tabs_13422179865124776 new file mode 100644 index 0000000..d230faf Binary files /dev/null and b/my_profile/Default/Sessions/Tabs_13422179865124776 differ diff --git a/my_profile/Default/Shared Dictionary/cache/index b/my_profile/Default/Shared Dictionary/cache/index new file mode 100644 index 0000000..79bd403 Binary files /dev/null and b/my_profile/Default/Shared Dictionary/cache/index differ diff --git a/my_profile/Default/Shared Dictionary/cache/index-dir/the-real-index b/my_profile/Default/Shared Dictionary/cache/index-dir/the-real-index new file mode 100644 index 0000000..722c08f Binary files /dev/null and b/my_profile/Default/Shared Dictionary/cache/index-dir/the-real-index differ diff --git a/my_profile/Default/Shared Dictionary/db b/my_profile/Default/Shared Dictionary/db new file mode 100644 index 0000000..49646ff Binary files /dev/null and b/my_profile/Default/Shared Dictionary/db differ diff --git a/my_profile/Default/Shared Dictionary/db-journal b/my_profile/Default/Shared Dictionary/db-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/SharedStorage b/my_profile/Default/SharedStorage new file mode 100644 index 0000000..159e90a Binary files /dev/null and b/my_profile/Default/SharedStorage differ diff --git a/my_profile/Default/Site Characteristics Database/CURRENT b/my_profile/Default/Site Characteristics Database/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Site Characteristics Database/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Site Characteristics Database/LOCK b/my_profile/Default/Site Characteristics Database/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Site Characteristics Database/LOG b/my_profile/Default/Site Characteristics Database/LOG new file mode 100644 index 0000000..527d1b9 --- /dev/null +++ b/my_profile/Default/Site Characteristics Database/LOG @@ -0,0 +1,2 @@ +2026/05/02-16:17:44.432 3dfd0b Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Site Characteristics Database since it was missing. +2026/05/02-16:17:44.436 3dfd0b Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Site Characteristics Database/MANIFEST-000001 diff --git a/my_profile/Default/Site Characteristics Database/MANIFEST-000001 b/my_profile/Default/Site Characteristics Database/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Site Characteristics Database/MANIFEST-000001 differ diff --git a/my_profile/Default/Sync Data/LevelDB/CURRENT b/my_profile/Default/Sync Data/LevelDB/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/Sync Data/LevelDB/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/Sync Data/LevelDB/LOCK b/my_profile/Default/Sync Data/LevelDB/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Sync Data/LevelDB/LOG b/my_profile/Default/Sync Data/LevelDB/LOG new file mode 100644 index 0000000..10710b5 --- /dev/null +++ b/my_profile/Default/Sync Data/LevelDB/LOG @@ -0,0 +1,2 @@ +2026/05/02-16:17:44.425 3dfcfc Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Sync Data/LevelDB since it was missing. +2026/05/02-16:17:44.435 3dfcfc Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/Sync Data/LevelDB/MANIFEST-000001 diff --git a/my_profile/Default/Sync Data/LevelDB/MANIFEST-000001 b/my_profile/Default/Sync Data/LevelDB/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/Sync Data/LevelDB/MANIFEST-000001 differ diff --git a/my_profile/Default/Top Sites b/my_profile/Default/Top Sites new file mode 100644 index 0000000..c5ce8e9 Binary files /dev/null and b/my_profile/Default/Top Sites differ diff --git a/my_profile/Default/Top Sites-journal b/my_profile/Default/Top Sites-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/TransportSecurity b/my_profile/Default/TransportSecurity new file mode 100644 index 0000000..1bfd975 --- /dev/null +++ b/my_profile/Default/TransportSecurity @@ -0,0 +1 @@ +{"sts":[{"expiry":1809242264.920548,"host":"5EdUoB7YUY9zZV+2DkgVXgho8WUvp+D+6KpeUOhNQIM=","mode":"force-https","sts_include_subdomains":false,"sts_observed":1777706264.920549},{"expiry":1809242264.918215,"host":"8/RrMmQlCD2Gsp14wUCE1P8r7B2C5+yE0+g79IPyRsc=","mode":"force-https","sts_include_subdomains":true,"sts_observed":1777706264.918218}],"version":2} \ No newline at end of file diff --git a/my_profile/Default/Trust Tokens b/my_profile/Default/Trust Tokens new file mode 100644 index 0000000..e128fda Binary files /dev/null and b/my_profile/Default/Trust Tokens differ diff --git a/my_profile/Default/Trust Tokens-journal b/my_profile/Default/Trust Tokens-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/Web Data b/my_profile/Default/Web Data new file mode 100644 index 0000000..ae8ab2f Binary files /dev/null and b/my_profile/Default/Web Data differ diff --git a/my_profile/Default/Web Data-journal b/my_profile/Default/Web Data-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/WebStorage/QuotaManager b/my_profile/Default/WebStorage/QuotaManager new file mode 100644 index 0000000..5508394 Binary files /dev/null and b/my_profile/Default/WebStorage/QuotaManager differ diff --git a/my_profile/Default/WebStorage/QuotaManager-journal b/my_profile/Default/WebStorage/QuotaManager-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/chrome_cart_db/LOCK b/my_profile/Default/chrome_cart_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/chrome_cart_db/LOG b/my_profile/Default/chrome_cart_db/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/commerce_subscription_db/LOCK b/my_profile/Default/commerce_subscription_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/commerce_subscription_db/LOG b/my_profile/Default/commerce_subscription_db/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/discount_infos_db/LOCK b/my_profile/Default/discount_infos_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/discount_infos_db/LOG b/my_profile/Default/discount_infos_db/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/discounts_db/LOCK b/my_profile/Default/discounts_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/discounts_db/LOG b/my_profile/Default/discounts_db/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/engine_allowlist.bf b/my_profile/Default/engine_allowlist.bf new file mode 100644 index 0000000..871816f Binary files /dev/null and b/my_profile/Default/engine_allowlist.bf differ diff --git a/my_profile/Default/parcel_tracking_db/LOCK b/my_profile/Default/parcel_tracking_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/parcel_tracking_db/LOG b/my_profile/Default/parcel_tracking_db/LOG new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/shared_proto_db/CURRENT b/my_profile/Default/shared_proto_db/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/shared_proto_db/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/shared_proto_db/LOCK b/my_profile/Default/shared_proto_db/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/shared_proto_db/LOG b/my_profile/Default/shared_proto_db/LOG new file mode 100644 index 0000000..d11618e --- /dev/null +++ b/my_profile/Default/shared_proto_db/LOG @@ -0,0 +1,2 @@ +2026/05/02-16:17:44.748 3dfd0b Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/shared_proto_db since it was missing. +2026/05/02-16:17:44.751 3dfd0b Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/shared_proto_db/MANIFEST-000001 diff --git a/my_profile/Default/shared_proto_db/MANIFEST-000001 b/my_profile/Default/shared_proto_db/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/shared_proto_db/MANIFEST-000001 differ diff --git a/my_profile/Default/shared_proto_db/metadata/CURRENT b/my_profile/Default/shared_proto_db/metadata/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/my_profile/Default/shared_proto_db/metadata/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/my_profile/Default/shared_proto_db/metadata/LOCK b/my_profile/Default/shared_proto_db/metadata/LOCK new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/Default/shared_proto_db/metadata/LOG b/my_profile/Default/shared_proto_db/metadata/LOG new file mode 100644 index 0000000..71f7182 --- /dev/null +++ b/my_profile/Default/shared_proto_db/metadata/LOG @@ -0,0 +1,2 @@ +2026/05/02-16:17:44.744 3dfd0b Creating DB /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/shared_proto_db/metadata since it was missing. +2026/05/02-16:17:44.747 3dfd0b Reusing MANIFEST /Users/legojeon/Documents/learnsteam/baseball-automation/my_profile/Default/shared_proto_db/metadata/MANIFEST-000001 diff --git a/my_profile/Default/shared_proto_db/metadata/MANIFEST-000001 b/my_profile/Default/shared_proto_db/metadata/MANIFEST-000001 new file mode 100644 index 0000000..18e5cab Binary files /dev/null and b/my_profile/Default/shared_proto_db/metadata/MANIFEST-000001 differ diff --git a/my_profile/Default/trusted_vault.pb b/my_profile/Default/trusted_vault.pb new file mode 100644 index 0000000..5f83178 --- /dev/null +++ b/my_profile/Default/trusted_vault.pb @@ -0,0 +1,2 @@ + + 0ba4067c95d8d92744702afdd1697107,FMha/UXuYCgxOs6kA5eqLYr/3JE3lSTZCKkpGExmGqM= \ No newline at end of file diff --git a/my_profile/GrShaderCache/data_0 b/my_profile/GrShaderCache/data_0 new file mode 100644 index 0000000..d76fb77 Binary files /dev/null and b/my_profile/GrShaderCache/data_0 differ diff --git a/my_profile/GrShaderCache/data_1 b/my_profile/GrShaderCache/data_1 new file mode 100644 index 0000000..dcaafa9 Binary files /dev/null and b/my_profile/GrShaderCache/data_1 differ diff --git a/my_profile/GrShaderCache/data_2 b/my_profile/GrShaderCache/data_2 new file mode 100644 index 0000000..c7e2eb9 Binary files /dev/null and b/my_profile/GrShaderCache/data_2 differ diff --git a/my_profile/GrShaderCache/data_3 b/my_profile/GrShaderCache/data_3 new file mode 100644 index 0000000..5eec973 Binary files /dev/null and b/my_profile/GrShaderCache/data_3 differ diff --git a/my_profile/GrShaderCache/index b/my_profile/GrShaderCache/index new file mode 100644 index 0000000..4f75ae6 Binary files /dev/null and b/my_profile/GrShaderCache/index differ diff --git a/my_profile/GraphiteDawnCache/data_0 b/my_profile/GraphiteDawnCache/data_0 new file mode 100644 index 0000000..fccd644 Binary files /dev/null and b/my_profile/GraphiteDawnCache/data_0 differ diff --git a/my_profile/GraphiteDawnCache/data_1 b/my_profile/GraphiteDawnCache/data_1 new file mode 100644 index 0000000..51012fa Binary files /dev/null and b/my_profile/GraphiteDawnCache/data_1 differ diff --git a/my_profile/GraphiteDawnCache/data_2 b/my_profile/GraphiteDawnCache/data_2 new file mode 100644 index 0000000..890cec1 Binary files /dev/null and b/my_profile/GraphiteDawnCache/data_2 differ diff --git a/my_profile/GraphiteDawnCache/data_3 b/my_profile/GraphiteDawnCache/data_3 new file mode 100644 index 0000000..c539b36 Binary files /dev/null and b/my_profile/GraphiteDawnCache/data_3 differ diff --git a/my_profile/GraphiteDawnCache/index b/my_profile/GraphiteDawnCache/index new file mode 100644 index 0000000..03b6272 Binary files /dev/null and b/my_profile/GraphiteDawnCache/index differ diff --git a/my_profile/Last Version b/my_profile/Last Version new file mode 100644 index 0000000..0cfed18 --- /dev/null +++ b/my_profile/Last Version @@ -0,0 +1 @@ +147.0.7727.138 \ No newline at end of file diff --git a/my_profile/Local State b/my_profile/Local State new file mode 100644 index 0000000..4f09a9c --- /dev/null +++ b/my_profile/Local State @@ -0,0 +1 @@ +{"autofill":{"ablation_seed":"Lmv9A96S64g="},"breadcrumbs":{"enabled":false,"enabled_time":"13422179864411227"},"browser":{"whats_new":{"enabled_order":["SyncAccountSettings","PdfInk2"]}},"hardware_acceleration_mode_previous":true,"legacy":{"profile":{"name":{"migrated":true}}},"local":{"password_hash_data_list":[]},"management":{"platform":{"enterprise_mdm_mac":0}},"network_time":{"network_time_mapping":{"local":1.777706264871497e+12,"network":1.77770626489e+12,"ticks":140708752402.0,"uncertainty":10162656.0}},"optimization_guide":{"model_execution":{"last_usage_by_feature":{}},"model_store_metadata":{},"on_device":{"last_version":"147.0.7727.138","model_crash_count":0}},"performance_intervention":{"last_daily_sample":"13422179864515040"},"performance_tuning":{"last_battery_use":{"timestamp":"13422179864515192"}},"policy":{"last_statistics_update":"13422179864410127"},"profile":{"info_cache":{"Default":{"active_time":1777706264.508457,"avatar_icon":"chrome://theme/IDR_PROFILE_AVATAR_26","background_apps":false,"default_avatar_fill_color":-14737376,"default_avatar_stroke_color":-3684409,"force_signin_profile_locked":false,"gaia_id":"","is_consented_primary_account":false,"is_ephemeral":false,"is_using_default_avatar":true,"is_using_default_name":true,"managed_user_id":"","metrics_bucket_index":1,"name":"내 Chrome","profile_color_seed":-5715974,"profile_highlight_color":-14737376,"signin.with_credential_provider":false,"user_name":""}},"last_active_profiles":["Default"],"metrics":{"next_bucket_index":2},"profile_counts_reported":"13422179864411704","profiles_order":["Default"]},"profile_network_context_service":{"http_cache_finch_experiment_groups":"None None None None"},"session_id_generator_last_value":"1447454004","signin":{"active_accounts_last_emitted":"13422179864384060"},"subresource_filter":{"ruleset_version":{"checksum":0,"content":"","format":0}},"tab_stats":{"discards_external":0,"discards_frozen":0,"discards_proactive":0,"discards_suggested":0,"discards_urgent":0,"last_daily_sample":"13422179864407862","max_tabs_per_window":1,"reloads_external":0,"reloads_frozen":0,"reloads_proactive":0,"reloads_suggested":0,"reloads_urgent":0,"total_tab_count_max":1,"window_count_max":1},"toast":{"non_milestone_update_toast_version":"147.0.7727.138"},"ukm":{"persisted_logs":[]},"uninstall_metrics":{"installation_date2":"1777706264"},"updateclientdata":{"apps":{"{cd7cc169-013a-4c79-94bc-cf5c8d78cccf}":{"dla":-1,"installdate":-1}}},"user_experience_metrics":{"client_id2":"9279f52f-650d-4327-bb6b-9570c334cc16","client_id_timestamp":"1777706264","limited_entropy_randomization_source":"90C66DB1B2B58ADF10B266BF2EA1F5AE","log_record_id":1,"low_entropy_source3":5684,"provisional_client_id":"eeeaba37-0854-42bf-9568-92dc6c7b3381","pseudo_low_entropy_source":4684,"session_id":0,"stability":{"browser_last_live_timestamp":"13422179865124331","exited_cleanly":true,"saved_system_profile":"CMaGv88GEhExNDcuMC43NzI3LjEzOC02NBjwwdbPBiICa28qEgoITWFjIE9TIFgSBjE1LjcuNDJTCgVhcm02NBCAgAEiCE1hYzE2LDEzKAEw3Bo4phFCCggAEAAaADIAOgBlAAAAQGoRCgd1bmtub3duEAAYCiAAKAaCAQCKAQCqAQZBUk1fNjSwAQFKCg1BkPK2FYCNfcpKCg2St1ezFTCu8txKCg0FDvD0FYCNfcpQBFoAYgRHR1JPaggIABAAOABAAIAB8MHWzwaYAQD4AbQsgAL///////////8BiAIAkgIkOTI3OWY1MmYtNjUwZC00MzI3LWJiNmItOTU3MGMzMzRjYzE2qALMJLICnAFkHjnSnstQPTfirX7FiAAqfsiwbNubM6kLj70v2MAtsf5bz4dGdDQ67motfyp+LMUZDNsJTLRUE0n9H/9UH43yOKroA8plfhtjdx59eUuqaR4MRo3j+04u52w1YviiiC9v2UFh7INtJFaBCRbIQET+tRCH0tbGCqYb9QXxOQvdoidEg0ktvAubY+tt3VOfvs9C4OPrhIu4/0zzDV3xAu+ovy6aq5S7","saved_system_profile_hash":"9DDB8F2625E837EC6678A822D1477C25B50B6F59","stats_buildtime":"1777320774","stats_version":"147.0.7727.138-64"}},"variations_google_groups":{"Default":[]},"was":{"restarted":false}} \ No newline at end of file diff --git a/my_profile/ShaderCache/data_0 b/my_profile/ShaderCache/data_0 new file mode 100644 index 0000000..d76fb77 Binary files /dev/null and b/my_profile/ShaderCache/data_0 differ diff --git a/my_profile/ShaderCache/data_1 b/my_profile/ShaderCache/data_1 new file mode 100644 index 0000000..dcaafa9 Binary files /dev/null and b/my_profile/ShaderCache/data_1 differ diff --git a/my_profile/ShaderCache/data_2 b/my_profile/ShaderCache/data_2 new file mode 100644 index 0000000..c7e2eb9 Binary files /dev/null and b/my_profile/ShaderCache/data_2 differ diff --git a/my_profile/ShaderCache/data_3 b/my_profile/ShaderCache/data_3 new file mode 100644 index 0000000..5eec973 Binary files /dev/null and b/my_profile/ShaderCache/data_3 differ diff --git a/my_profile/ShaderCache/index b/my_profile/ShaderCache/index new file mode 100644 index 0000000..8cf927e Binary files /dev/null and b/my_profile/ShaderCache/index differ diff --git a/my_profile/Variations b/my_profile/Variations new file mode 100644 index 0000000..18056c3 --- /dev/null +++ b/my_profile/Variations @@ -0,0 +1 @@ +{"user_experience_metrics.stability.exited_cleanly":true,"variations_crash_streak":0} \ No newline at end of file diff --git a/my_profile/first_party_sets.db b/my_profile/first_party_sets.db new file mode 100644 index 0000000..c43377c Binary files /dev/null and b/my_profile/first_party_sets.db differ diff --git a/my_profile/first_party_sets.db-journal b/my_profile/first_party_sets.db-journal new file mode 100644 index 0000000..e69de29 diff --git a/my_profile/segmentation_platform/ukm_db b/my_profile/segmentation_platform/ukm_db new file mode 100644 index 0000000..1047b62 Binary files /dev/null and b/my_profile/segmentation_platform/ukm_db differ diff --git a/my_profile/segmentation_platform/ukm_db-wal b/my_profile/segmentation_platform/ukm_db-wal new file mode 100644 index 0000000..e69de29 diff --git a/site.txt b/site.txt new file mode 100644 index 0000000..5444a90 --- /dev/null +++ b/site.txt @@ -0,0 +1,6 @@ +http://58.229.253.168:8089/manager/game/list + - 로비 +http://58.229.253.168:8089/manager/game/write?id={게임번호} + - 게임 등록, 라인업 입력 +http://58.229.253.168:8089/manager/game/status?game_no={게임번호} + - 입력 \ No newline at end of file