Integrate scoring play analysis into game detail retrieval.
- Naver Sports의 highLight 엔드포인트를 통해 득점 발생 타석의 상세 데이터를 추출하는 기능을 구현했습니다. - 단순 이닝별 점수 보강을 넘어 타석 결과, 주자 이동, 아웃 카운트 및 베이스 상태 변화를 포함한 정밀한 정보를 제공합니다. - KBO와 네이버 간의 경기 ID 체계 차이를 자동으로 처리하며, 병렬 요청 구조로 전체 조회 성능을 최적화했습니다. - 득점 주자의 출루 위치와 타점 발생 상황을 구조화된 데이터로 파싱하여 상세 조회 기능을 고도화했습니다.
This commit is contained in:
parent
5888d446b7
commit
5149ecf82a
@ -1,6 +1,7 @@
|
|||||||
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
|
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
|
||||||
import { LeagueCode, LEAGUE_CODES } from "./game-list.js";
|
import { LeagueCode, LEAGUE_CODES } from "./game-list.js";
|
||||||
import { SERIES_ID } from "./schedule.js";
|
import { SERIES_ID } from "./schedule.js";
|
||||||
|
import { fetchScoringPlays, type GameScoringPlays } from "./play-by-play.js";
|
||||||
|
|
||||||
const BASE_URL = "https://www.koreabaseball.com/ws/Schedule.asmx";
|
const BASE_URL = "https://www.koreabaseball.com/ws/Schedule.asmx";
|
||||||
|
|
||||||
@ -284,58 +285,6 @@ export async function fetchScoreBoardScroll(
|
|||||||
return parseScoreBoardScroll(raw);
|
return parseScoreBoardScroll(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1b. Naver 이닝별 득점 보강 ──
|
|
||||||
// KBO ScoreBoardScroll은 이닝별 득점(table2)을 비워서 내려주는 경우가 있어,
|
|
||||||
// Naver Sports relay 엔드포인트의 inningScore만 가져와 보강한다.
|
|
||||||
|
|
||||||
interface NaverRelayResponse {
|
|
||||||
code: number;
|
|
||||||
success: boolean;
|
|
||||||
result?: {
|
|
||||||
textRelayData?: {
|
|
||||||
inningScore?: {
|
|
||||||
home?: Record<string, string>;
|
|
||||||
away?: Record<string, string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchNaverInningScores(
|
|
||||||
gameId: string
|
|
||||||
): Promise<InningScore[] | null> {
|
|
||||||
const url = `https://api-gw.sports.naver.com/schedule/games/${encodeURIComponent(gameId)}/relay`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const json = (await res.json()) as NaverRelayResponse;
|
|
||||||
const score = json.result?.textRelayData?.inningScore;
|
|
||||||
if (!score) return null;
|
|
||||||
|
|
||||||
const inningKeys = new Set<number>();
|
|
||||||
for (const k of Object.keys(score.home ?? {})) {
|
|
||||||
const n = parseInt(k, 10);
|
|
||||||
if (Number.isFinite(n)) inningKeys.add(n);
|
|
||||||
}
|
|
||||||
for (const k of Object.keys(score.away ?? {})) {
|
|
||||||
const n = parseInt(k, 10);
|
|
||||||
if (Number.isFinite(n)) inningKeys.add(n);
|
|
||||||
}
|
|
||||||
if (inningKeys.size === 0) return null;
|
|
||||||
|
|
||||||
const sorted = [...inningKeys].sort((a, b) => a - b);
|
|
||||||
return sorted.map((inning) => ({
|
|
||||||
inning,
|
|
||||||
away: toInt(score.away?.[String(inning)]),
|
|
||||||
home: toInt(score.home?.[String(inning)]),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasInningScores(innings: InningScore[]): boolean {
|
|
||||||
return innings.some((s) => s.away != null || s.home != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. BoxScoreScroll ──
|
// ── 2. BoxScoreScroll ──
|
||||||
|
|
||||||
interface RawBoxScoreScroll {
|
interface RawBoxScoreScroll {
|
||||||
@ -624,21 +573,26 @@ export interface GameDetail {
|
|||||||
hitter: KeyPlayerRanking;
|
hitter: KeyPlayerRanking;
|
||||||
pitcher: KeyPlayerRanking;
|
pitcher: KeyPlayerRanking;
|
||||||
};
|
};
|
||||||
|
/** Naver highLight에서 추출한 득점 타석 목록. 호출 실패 시 null. */
|
||||||
|
scoringPlays: GameScoringPlays | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameDetail(
|
export async function fetchGameDetail(
|
||||||
filters: GameDetailFilters
|
filters: GameDetailFilters
|
||||||
): Promise<GameDetail> {
|
): Promise<GameDetail> {
|
||||||
const [scoreBoard, boxScore, keyHitter, keyPitcher] = await Promise.all([
|
const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays] =
|
||||||
|
await Promise.all([
|
||||||
fetchScoreBoardScroll(filters),
|
fetchScoreBoardScroll(filters),
|
||||||
fetchBoxScoreScroll(filters),
|
fetchBoxScoreScroll(filters),
|
||||||
fetchKeyPlayerHitter(filters),
|
fetchKeyPlayerHitter(filters),
|
||||||
fetchKeyPlayerPitcher(filters),
|
fetchKeyPlayerPitcher(filters),
|
||||||
|
fetchScoringPlays(filters.gameId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
gameId: filters.gameId,
|
gameId: filters.gameId,
|
||||||
scoreBoard,
|
scoreBoard,
|
||||||
boxScore,
|
boxScore,
|
||||||
keyPlayer: { hitter: keyHitter, pitcher: keyPitcher },
|
keyPlayer: { hitter: keyHitter, pitcher: keyPitcher },
|
||||||
|
scoringPlays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
497
src/kbo/play-by-play.ts
Normal file
497
src/kbo/play-by-play.ts
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
// Naver Sports의 highLight 엔드포인트에서 득점이 발생한 타석만 추출한다.
|
||||||
|
//
|
||||||
|
// GET https://api-gw.sports.naver.com/schedule/games/{gameId}/relay/highLight
|
||||||
|
//
|
||||||
|
// 한 번의 호출로 전체 경기의 득점 타석이 시간 역순으로 모두 들어온다. KBO
|
||||||
|
// 공식 응답에 없는 다음 정보를 보강한다:
|
||||||
|
//
|
||||||
|
// - 어느 타석에서 점수가 났는지 (타자·결과 텍스트)
|
||||||
|
// - 누가 어느 베이스에서 홈인했는지
|
||||||
|
// - 그 시점의 아웃 카운트·스코어 추이
|
||||||
|
//
|
||||||
|
// 인증 불필요. 종료된 경기는 데이터 불변, 라이브 중에는 그 시점까지 누적된
|
||||||
|
// 득점만 들어온다.
|
||||||
|
|
||||||
|
const USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
const HIGHLIGHT_BASE = "https://api-gw.sports.naver.com/schedule/games";
|
||||||
|
|
||||||
|
// ── Public types ──
|
||||||
|
|
||||||
|
export type ScoringOutcome =
|
||||||
|
| "homerun"
|
||||||
|
| "single"
|
||||||
|
| "double"
|
||||||
|
| "triple"
|
||||||
|
| "walk"
|
||||||
|
| "hit_by_pitch"
|
||||||
|
| "sac_fly"
|
||||||
|
| "sac_bunt"
|
||||||
|
| "fielders_choice"
|
||||||
|
| "error"
|
||||||
|
| "wild_pitch"
|
||||||
|
| "passed_ball"
|
||||||
|
| "balk"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface BaseRunner {
|
||||||
|
pcode: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseState {
|
||||||
|
first: BaseRunner | null;
|
||||||
|
second: BaseRunner | null;
|
||||||
|
third: BaseRunner | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoringRunner {
|
||||||
|
/** 1·2·3루 주자였는지, "batter"이면 타자가 직접 홈인(홈런 등). */
|
||||||
|
fromBase: 1 | 2 | 3 | "batter";
|
||||||
|
name: string;
|
||||||
|
/** "실책으로 홈인" 같은 부가 설명. */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoringBatter {
|
||||||
|
pcode: string;
|
||||||
|
name: string;
|
||||||
|
batOrder: number;
|
||||||
|
position: string;
|
||||||
|
seasonAvg: number;
|
||||||
|
todayAvg?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoringPitcher {
|
||||||
|
pcode: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoringPlay {
|
||||||
|
inning: number;
|
||||||
|
half: "top" | "bottom";
|
||||||
|
/** 공격팀 코드 (gameId에서 파싱). 표시용 이름은 KBO scoreBoard에서 매핑. */
|
||||||
|
battingTeamCode: string;
|
||||||
|
|
||||||
|
/** 타석 진입 직후~결과 직전 상태. */
|
||||||
|
outsBefore: 0 | 1 | 2;
|
||||||
|
basesBefore: BaseState;
|
||||||
|
scoreBefore: { home: number; away: number };
|
||||||
|
|
||||||
|
/** 타석 종료 시점. */
|
||||||
|
outsAfter: 0 | 1 | 2 | 3;
|
||||||
|
basesAfter: BaseState;
|
||||||
|
scoreAfter: { home: number; away: number };
|
||||||
|
|
||||||
|
/** 이 타석에서 들어온 점수 수. */
|
||||||
|
runsScored: number;
|
||||||
|
|
||||||
|
batter: ScoringBatter;
|
||||||
|
pitcher: ScoringPitcher;
|
||||||
|
|
||||||
|
outcome: ScoringOutcome;
|
||||||
|
/** 타석 결과 원문. 예: "박건우 : 우익수 뒤 홈런 (홈런거리:110M)" */
|
||||||
|
resultText: string;
|
||||||
|
/** 결과 부가 정보. 예: "홈런거리:110M" */
|
||||||
|
resultDetail?: string;
|
||||||
|
|
||||||
|
/** 이 타석으로 홈인한 주자들. 시간순. */
|
||||||
|
scoringRunners: ScoringRunner[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameScoringPlays {
|
||||||
|
gameId: string;
|
||||||
|
plays: ScoringPlay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Naver raw shapes ──
|
||||||
|
|
||||||
|
interface NaverLineupBatter {
|
||||||
|
pcode?: string;
|
||||||
|
name?: string;
|
||||||
|
batOrder?: number;
|
||||||
|
posName?: string;
|
||||||
|
seasonHra?: number;
|
||||||
|
todayHra?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverLineupPitcher {
|
||||||
|
pcode?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverEntryPlayer {
|
||||||
|
pcode?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverGameState {
|
||||||
|
homeScore?: string;
|
||||||
|
awayScore?: string;
|
||||||
|
pitcher?: string;
|
||||||
|
batter?: string;
|
||||||
|
out?: string;
|
||||||
|
base1?: string;
|
||||||
|
base2?: string;
|
||||||
|
base3?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverBatterRecord {
|
||||||
|
pcode?: string;
|
||||||
|
name?: string;
|
||||||
|
batOrder?: number;
|
||||||
|
posName?: string;
|
||||||
|
seasonHra?: number;
|
||||||
|
todayHra?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverTextOption {
|
||||||
|
text?: string;
|
||||||
|
type?: number;
|
||||||
|
seqno?: number;
|
||||||
|
currentGameState?: NaverGameState;
|
||||||
|
batterRecord?: NaverBatterRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverTextRelay {
|
||||||
|
title?: string;
|
||||||
|
no?: number;
|
||||||
|
inn?: number;
|
||||||
|
homeOrAway?: string;
|
||||||
|
textOptions?: NaverTextOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverHighLightData {
|
||||||
|
gameId?: string;
|
||||||
|
textRelays?: NaverTextRelay[];
|
||||||
|
homeEntry?: { batter?: NaverEntryPlayer[]; pitcher?: NaverEntryPlayer[] };
|
||||||
|
awayEntry?: { batter?: NaverEntryPlayer[]; pitcher?: NaverEntryPlayer[] };
|
||||||
|
homeLineup?: { batter?: NaverLineupBatter[]; pitcher?: NaverLineupPitcher[] };
|
||||||
|
awayLineup?: { batter?: NaverLineupBatter[]; pitcher?: NaverLineupPitcher[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NaverHighLightResponse {
|
||||||
|
result?: { textRelayData?: NaverHighLightData };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
interface PlayerInfo {
|
||||||
|
pcode: string;
|
||||||
|
name: string;
|
||||||
|
batOrder?: number;
|
||||||
|
position?: string;
|
||||||
|
seasonAvg?: number;
|
||||||
|
todayAvg?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PcodeMap = Map<string, PlayerInfo>;
|
||||||
|
type BatOrderMap = Map<number, BaseRunner>;
|
||||||
|
|
||||||
|
function buildPcodeMap(data: NaverHighLightData): PcodeMap {
|
||||||
|
const map: PcodeMap = new Map();
|
||||||
|
const upsert = (info: PlayerInfo) => {
|
||||||
|
if (!info.pcode) return;
|
||||||
|
const prev = map.get(info.pcode);
|
||||||
|
map.set(info.pcode, {
|
||||||
|
pcode: info.pcode,
|
||||||
|
name: info.name || prev?.name || "",
|
||||||
|
batOrder: info.batOrder ?? prev?.batOrder,
|
||||||
|
position: info.position ?? prev?.position,
|
||||||
|
seasonAvg: info.seasonAvg ?? prev?.seasonAvg,
|
||||||
|
todayAvg: info.todayAvg ?? prev?.todayAvg,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const src of [data.homeLineup, data.awayLineup]) {
|
||||||
|
for (const b of src?.batter ?? []) {
|
||||||
|
if (!b.pcode) continue;
|
||||||
|
upsert({
|
||||||
|
pcode: b.pcode,
|
||||||
|
name: b.name ?? "",
|
||||||
|
batOrder: b.batOrder,
|
||||||
|
position: b.posName,
|
||||||
|
seasonAvg: b.seasonHra,
|
||||||
|
todayAvg: b.todayHra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of src?.pitcher ?? []) {
|
||||||
|
if (!p.pcode) continue;
|
||||||
|
upsert({ pcode: p.pcode, name: p.name ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const src of [data.homeEntry, data.awayEntry]) {
|
||||||
|
for (const b of src?.batter ?? []) {
|
||||||
|
if (!b.pcode) continue;
|
||||||
|
upsert({ pcode: b.pcode, name: b.name ?? "" });
|
||||||
|
}
|
||||||
|
for (const p of src?.pitcher ?? []) {
|
||||||
|
if (!p.pcode) continue;
|
||||||
|
upsert({ pcode: p.pcode, name: p.name ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 베이스 필드(`base1/2/3`)에는 pcode가 아니라 그 슬롯에 있는 주자의 batOrder
|
||||||
|
// 코드(1~9 string, "0"=빈루)가 들어온다. batOrder→{pcode,name} 매핑을 양팀
|
||||||
|
// 라인업에서 빌드해 두면 베이스 점유 주자 식별이 가능하다.
|
||||||
|
function buildBatOrderMap(
|
||||||
|
lineup: { batter?: NaverLineupBatter[] } | undefined
|
||||||
|
): BatOrderMap {
|
||||||
|
const map: BatOrderMap = new Map();
|
||||||
|
for (const b of lineup?.batter ?? []) {
|
||||||
|
if (!b.batOrder || !b.pcode) continue;
|
||||||
|
if (!map.has(b.batOrder)) {
|
||||||
|
map.set(b.batOrder, { pcode: b.pcode, name: b.name ?? "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntStrict(s: string | undefined, fallback: number): number {
|
||||||
|
if (s == null) return fallback;
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOuts0to2(n: number): 0 | 1 | 2 {
|
||||||
|
if (n <= 0) return 0;
|
||||||
|
if (n >= 2) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOuts0to3(n: number): 0 | 1 | 2 | 3 {
|
||||||
|
if (n <= 0) return 0;
|
||||||
|
if (n >= 3) return 3;
|
||||||
|
return n as 1 | 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBaseState(
|
||||||
|
state: NaverGameState | undefined,
|
||||||
|
battingTeamMap: BatOrderMap
|
||||||
|
): BaseState {
|
||||||
|
const slot = (raw: string | undefined): BaseRunner | null => {
|
||||||
|
if (!raw || raw === "0") return null;
|
||||||
|
const order = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(order)) return null;
|
||||||
|
const looked = battingTeamMap.get(order);
|
||||||
|
if (looked) return { pcode: looked.pcode, name: looked.name };
|
||||||
|
// 매칭 실패 시 raw 코드 보존
|
||||||
|
return { pcode: raw, name: "" };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
first: slot(state?.base1),
|
||||||
|
second: slot(state?.base2),
|
||||||
|
third: slot(state?.base3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameId 형식: `YYYYMMDD{AWAY}{HOME}{N}{YYYY}` (예: 20260429HTNC02026 → HT/NC).
|
||||||
|
function parseGameIdTeams(gameId: string): { away: string; home: string } {
|
||||||
|
const m = gameId.match(/^\d{8}([A-Z]{2})([A-Z]{2})/);
|
||||||
|
if (!m) return { away: "", home: "" };
|
||||||
|
return { away: m[1], home: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// KBO 게임센터는 13자리(`YYYYMMDDXXYY0`)를, Naver는 17자리(`...YYYY` 시즌 suffix)
|
||||||
|
// 를 쓴다. 13자리가 들어오면 앞 4자리(연도)를 suffix로 붙여 17자리로 변환한다.
|
||||||
|
function toNaverGameId(gameId: string): string {
|
||||||
|
if (/^\d{8}[A-Z]{2}[A-Z]{2}\d{5}$/.test(gameId)) return gameId;
|
||||||
|
if (/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(gameId)) {
|
||||||
|
return gameId + gameId.slice(0, 4);
|
||||||
|
}
|
||||||
|
return gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── outcome 분류 ──
|
||||||
|
|
||||||
|
function classifyOutcome(text: string): ScoringOutcome {
|
||||||
|
if (/홈런/.test(text)) return "homerun";
|
||||||
|
if (/3루타/.test(text)) return "triple";
|
||||||
|
if (/2루타/.test(text)) return "double";
|
||||||
|
if (/희생플라이|희비/.test(text)) return "sac_fly";
|
||||||
|
if (/희생번트/.test(text)) return "sac_bunt";
|
||||||
|
if (/야수선택/.test(text)) return "fielders_choice";
|
||||||
|
if (/실책/.test(text)) return "error";
|
||||||
|
if (/4구|볼넷|고의4구/.test(text)) return "walk";
|
||||||
|
if (/사구|몸에\s*맞는/.test(text)) return "hit_by_pitch";
|
||||||
|
if (/안타|1루타/.test(text)) return "single";
|
||||||
|
if (/폭투/.test(text)) return "wild_pitch";
|
||||||
|
if (/포일|패스볼/.test(text)) return "passed_ball";
|
||||||
|
if (/보크/.test(text)) return "balk";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
// "X루주자 이름 : 홈인" / "X루주자 이름 : 실책으로 홈인" 같은 텍스트에서
|
||||||
|
// 주자 홈인 정보를 뽑는다. 진루·아웃·태그아웃 등 비득점 이벤트는 null.
|
||||||
|
function parseRunnerScoring(text: string): ScoringRunner | null {
|
||||||
|
const m = text.match(/([1-3])루주자\s+([^\s:]+)\s*:\s*(.+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const note = m[3].trim();
|
||||||
|
if (!/홈인/.test(note)) return null;
|
||||||
|
const fromBase = parseInt(m[1], 10) as 1 | 2 | 3;
|
||||||
|
const runner: ScoringRunner = { fromBase, name: m[2] };
|
||||||
|
if (note !== "홈인") runner.note = note;
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── per-entry parser ──
|
||||||
|
|
||||||
|
function parseScoringEntry(
|
||||||
|
entry: NaverTextRelay,
|
||||||
|
pcodeMap: PcodeMap,
|
||||||
|
homeBatOrder: BatOrderMap,
|
||||||
|
awayBatOrder: BatOrderMap,
|
||||||
|
teams: { away: string; home: string }
|
||||||
|
): ScoringPlay | null {
|
||||||
|
const opts = entry.textOptions ?? [];
|
||||||
|
if (opts.length === 0) return null;
|
||||||
|
|
||||||
|
const firstState = opts[0]?.currentGameState ?? {};
|
||||||
|
const lastState = opts[opts.length - 1]?.currentGameState ?? firstState;
|
||||||
|
|
||||||
|
const homeBefore = toIntStrict(firstState.homeScore, 0);
|
||||||
|
const awayBefore = toIntStrict(firstState.awayScore, 0);
|
||||||
|
const homeAfter = toIntStrict(lastState.homeScore, homeBefore);
|
||||||
|
const awayAfter = toIntStrict(lastState.awayScore, awayBefore);
|
||||||
|
const runsScored = Math.max(
|
||||||
|
0,
|
||||||
|
homeAfter - homeBefore + (awayAfter - awayBefore)
|
||||||
|
);
|
||||||
|
if (runsScored === 0) return null;
|
||||||
|
|
||||||
|
const half: "top" | "bottom" = entry.homeOrAway === "1" ? "bottom" : "top";
|
||||||
|
const battingTeamMap = half === "top" ? awayBatOrder : homeBatOrder;
|
||||||
|
const battingTeamCode = half === "top" ? teams.away : teams.home;
|
||||||
|
|
||||||
|
const outsBefore = clampOuts0to2(toIntStrict(firstState.out, 0));
|
||||||
|
const outsAfter = clampOuts0to3(
|
||||||
|
Math.max(toIntStrict(lastState.out, outsBefore), outsBefore)
|
||||||
|
);
|
||||||
|
|
||||||
|
const basesBefore = parseBaseState(firstState, battingTeamMap);
|
||||||
|
const basesAfter = parseBaseState(lastState, battingTeamMap);
|
||||||
|
|
||||||
|
// 결과 텍스트: type 23(타자 결과)을 우선, 없으면 마지막 textOption.text.
|
||||||
|
let resultOption: NaverTextOption | undefined;
|
||||||
|
for (const o of opts) {
|
||||||
|
if (o.type === 23) resultOption = o;
|
||||||
|
}
|
||||||
|
const resultText = resultOption?.text ?? opts[opts.length - 1].text ?? "";
|
||||||
|
const detailMatch = resultText.match(/\(([^)]+)\)/);
|
||||||
|
const outcome = classifyOutcome(resultText);
|
||||||
|
|
||||||
|
// 타자 / 투수
|
||||||
|
const br = opts.find((o) => o.batterRecord)?.batterRecord;
|
||||||
|
const batterPcode = br?.pcode ?? lastState.batter ?? firstState.batter ?? "";
|
||||||
|
const looked = pcodeMap.get(batterPcode);
|
||||||
|
const batter: ScoringBatter = {
|
||||||
|
pcode: batterPcode,
|
||||||
|
name: br?.name ?? looked?.name ?? "",
|
||||||
|
batOrder: br?.batOrder ?? looked?.batOrder ?? 0,
|
||||||
|
position: br?.posName ?? looked?.position ?? "",
|
||||||
|
seasonAvg: br?.seasonHra ?? looked?.seasonAvg ?? 0,
|
||||||
|
};
|
||||||
|
const todayAvg = br?.todayHra ?? looked?.todayAvg;
|
||||||
|
if (todayAvg != null) batter.todayAvg = todayAvg;
|
||||||
|
const pitcherPcode = lastState.pitcher ?? firstState.pitcher ?? "";
|
||||||
|
const pitcher: ScoringPitcher = {
|
||||||
|
pcode: pitcherPcode,
|
||||||
|
name: pcodeMap.get(pitcherPcode)?.name ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 주자 이동: type=24 이벤트와 type=23(홈런 시) 본인 홈인을 모은다.
|
||||||
|
const scoringRunners: ScoringRunner[] = [];
|
||||||
|
for (const o of opts) {
|
||||||
|
if (o.type === 24 && o.text) {
|
||||||
|
const r = parseRunnerScoring(o.text);
|
||||||
|
if (r) scoringRunners.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 타자 본인 홈인 (홈런이거나, 결과 텍스트에 본인 이름과 "홈인"이 함께)
|
||||||
|
const batterScored =
|
||||||
|
outcome === "homerun" ||
|
||||||
|
(batter.name && new RegExp(`${batter.name}\\s*:.*홈인`).test(resultText));
|
||||||
|
if (batterScored) {
|
||||||
|
scoringRunners.unshift({ fromBase: "batter", name: batter.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const play: ScoringPlay = {
|
||||||
|
inning: entry.inn ?? 0,
|
||||||
|
half,
|
||||||
|
battingTeamCode,
|
||||||
|
outsBefore,
|
||||||
|
basesBefore,
|
||||||
|
scoreBefore: { home: homeBefore, away: awayBefore },
|
||||||
|
outsAfter,
|
||||||
|
basesAfter,
|
||||||
|
scoreAfter: { home: homeAfter, away: awayAfter },
|
||||||
|
runsScored,
|
||||||
|
batter,
|
||||||
|
pitcher,
|
||||||
|
outcome,
|
||||||
|
resultText,
|
||||||
|
scoringRunners,
|
||||||
|
};
|
||||||
|
if (detailMatch) play.resultDetail = detailMatch[1];
|
||||||
|
return play;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public parse ──
|
||||||
|
|
||||||
|
export function parseScoringPlays(
|
||||||
|
raw: NaverHighLightResponse,
|
||||||
|
gameId: string
|
||||||
|
): GameScoringPlays {
|
||||||
|
const data = raw.result?.textRelayData;
|
||||||
|
if (!data || !Array.isArray(data.textRelays)) {
|
||||||
|
return { gameId, plays: [] };
|
||||||
|
}
|
||||||
|
const pcodeMap = buildPcodeMap(data);
|
||||||
|
const homeBatOrder = buildBatOrderMap(data.homeLineup);
|
||||||
|
const awayBatOrder = buildBatOrderMap(data.awayLineup);
|
||||||
|
const teams = parseGameIdTeams(gameId);
|
||||||
|
// textRelays는 보통 no DESC. 시간순(ASC) 정렬.
|
||||||
|
const sorted = [...data.textRelays].sort((a, b) => (a.no ?? 0) - (b.no ?? 0));
|
||||||
|
const plays: ScoringPlay[] = [];
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const play = parseScoringEntry(
|
||||||
|
entry,
|
||||||
|
pcodeMap,
|
||||||
|
homeBatOrder,
|
||||||
|
awayBatOrder,
|
||||||
|
teams
|
||||||
|
);
|
||||||
|
if (play) plays.push(play);
|
||||||
|
}
|
||||||
|
return { gameId, plays };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public fetch ──
|
||||||
|
|
||||||
|
export async function fetchScoringPlays(
|
||||||
|
gameId: string
|
||||||
|
): Promise<GameScoringPlays | null> {
|
||||||
|
const naverGameId = toNaverGameId(gameId);
|
||||||
|
const url = `${HIGHLIGHT_BASE}/${encodeURIComponent(naverGameId)}/relay/highLight`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) return null;
|
||||||
|
let json: NaverHighLightResponse;
|
||||||
|
try {
|
||||||
|
json = (await res.json()) as NaverHighLightResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!json.result?.textRelayData) return null;
|
||||||
|
// 호출자가 KBO 13자리를 넘겨도 결과의 gameId는 호출 시 입력값 그대로 보존.
|
||||||
|
return parseScoringPlays(json, gameId);
|
||||||
|
}
|
||||||
@ -52,4 +52,15 @@ export type {
|
|||||||
KeyPlayerGroup,
|
KeyPlayerGroup,
|
||||||
} from "../kbo/game-detail.js";
|
} from "../kbo/game-detail.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
GameScoringPlays,
|
||||||
|
ScoringPlay,
|
||||||
|
ScoringBatter,
|
||||||
|
ScoringPitcher,
|
||||||
|
ScoringRunner,
|
||||||
|
ScoringOutcome,
|
||||||
|
BaseState,
|
||||||
|
BaseRunner,
|
||||||
|
} from "../kbo/play-by-play.js";
|
||||||
|
|
||||||
export { SERIES_ID } from "../kbo/schedule.js";
|
export { SERIES_ID } from "../kbo/schedule.js";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user