diff --git a/CLAUDE.md b/CLAUDE.md index 01bdc4c..00437f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ GitHub Actions가 하루 2회(UTC 05:00·17:00) 호출. `maxDuration = 60`. 부 - 일정 크롤: CloudFront CDN API(`d29dxerjsp82wz.cloudfront.net`)는 폐기됨(404) → 실질적으로 `www.ufc.com/events` HTML 파싱이 주 소스 - 예측 생성: `eventId`로 중복 체크 — 기존 예측 재사용, 새 이벤트만 Gemini 호출 - 파이터 이미지: `enrichFighterImages()` — UFC 선수 페이지 병렬 스크레이핑 (최대 20명) -5. **고석현 확정 경기 감지** (Phase 2.5): `detectKoConfirmedFight(events, existingConfirmed)`가 일정에서 고석현 매치(`isKoSeokhyeon` 매처)를 찾아 `confirmedFight` 생성 후 `opponent_predictions.data`에 부착. 상대·대회가 기존과 같으면 Gemini 재호출 생략, 신규/변경 시에만 `analyzeConfirmedOpponent()`로 한국어명·국적·스타일 보강. 감지 실패는 non-blocking +5. **고석현 확정 경기 감지** (Phase 2.5): `detectKoConfirmedFight(events, existingConfirmed)`가 일정에서 고석현 매치(`isKoSeokhyeon` 매처)를 찾아 `confirmedFight` 생성 후 `opponent_predictions.data`에 부착. 상대·대회가 기존과 같고 신체 스펙(`height`)까지 있으면 Gemini 재호출 생략, 신규/변경 또는 스펙 미보강(구버전 데이터)이면 `analyzeConfirmedOpponent()`로 한국어명·국적·스타일·나이·신장/체중/리치·전적 보강(Tale of the Tape 비교용). 전적은 크롤값 우선, 크롤에 없을 때만 Gemini 폴백. 감지 실패는 non-blocking 크롤 완료 후 `revalidatePath()` + `fetch` 워밍으로 `/`, `/schedule`, `/predictions`, `/rankings` 한/영 캐시 갱신. diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 4c7655c..abd8c71 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -19,6 +19,7 @@ import careerHighlights from "@/data/career-highlights.json"; import fighterBio from "@/data/fighter-bio.json"; import { getPredictions } from "@/lib/data/predictions"; import { getRankings } from "@/lib/data/rankings"; +import { buildKoComparisonStats } from "@/lib/ko-stats"; import { fetchNews } from "@/lib/news"; import { searchYouTubeVideos } from "@/lib/youtube"; import type { @@ -155,6 +156,13 @@ export default async function HomePage({ getSchedule(), ]); + // 확정 경기 카드·예측 프리뷰의 Tale of the Tape 비교용 고석현 데이터 + const koStats = buildKoComparisonStats( + stats, + predictions.koFightMatrixRank, + predictions.lastFightDate + ); + const siteOrigin = (() => { const raw = process.env.NEXT_PUBLIC_SITE_URL || "https://lets-ko.vercel.app"; @@ -197,7 +205,11 @@ export default async function HomePage({ stats={stats} locale={locale} /> - + r.site === "FightMatrix")?.rank ?? - predictions.koFightMatrixRank; - - const koComparisonStats = { - record: `${stats.record.wins}-${stats.record.losses}-${stats.record.draws}`, - age: differenceInYears(new Date(), new Date("1993-09-24")), - height: "177.8cm", - weight: "77.1kg", - reach: "180.3cm", - style: { ko: "유도 / 삼보", en: "Judo / Sambo" }, - fightMatrixRank: koFightMatrixRank, - // stats.fightHistory가 더 신뢰할 수 있는 ISO 형식이므로 우선 사용 - lastFightDate: stats.fightHistory?.[0]?.date || predictions.lastFightDate, - }; + // 고석현 비교용 데이터 (cm/kg 단위, 메인 페이지와 공통 빌더 사용) + const koComparisonStats = buildKoComparisonStats( + stats, + predictions.koFightMatrixRank, + predictions.lastFightDate + ); return ( +
@@ -27,8 +30,19 @@ function FighterPlaceholder() { ); } +// "177.8cm" → "177.8" (미니 그리드의 KO 병기값은 공간 절약을 위해 단위 생략) +const stripUnit = (value: string): string => value.replace(/[^\d.]/g, ""); + +/** + * @description 메인 페이지 AI 다음 상대 예측 프리뷰 섹션. 확정 경기가 있으면 Tale of the Tape 카드, + * 없으면 후보 3인의 다크 카드(전적/나이/신장/리치 + 고석현 병기, AI 승률 바)를 표시. + * @param props.predictions - AI 상대 예측 데이터 + * @param props.koStats - 고석현 측 비교 데이터 (전적/나이/신장/리치 등) + * @param props.locale - "ko" | "en" + */ export default function PredictionPreview({ predictions, + koStats, locale, }: PredictionPreviewProps) { const t = useTranslations("predictions"); @@ -42,6 +56,7 @@ export default function PredictionPreview({
@@ -126,72 +141,142 @@ export default function PredictionPreview({

- {/* 카드 그리드 */} + {/* 후보 카드 그리드 */}
- {predictions.opponents.map((opponent, index) => ( - - {/* 배경 그라데이션 호버 효과 */} -
- -
- {/* 순위 배지 */} - - #{index + 1} - - - {/* 프로필 이미지 */} -
- {opponent.imageUrl && - !opponent.imageUrl.includes("placeholder") ? ( - {opponent.name[lang]} - ) : ( - - )} -
+ {predictions.opponents.map((opponent, index) => { + const total = + opponent.record.wins + + opponent.record.losses + + opponent.record.draws; + const winRate = + total > 0 ? Math.round((opponent.record.wins / total) * 100) : 0; + const koProb = opponent.winProbability; - {/* 이름 */} -

- {opponent.name[lang]} -

+ // 미니 비교 그리드: 상대 값 + 고석현(KO) 병기값 + const miniStats = [ + { + label: t("record"), + value: `${opponent.record.wins}-${opponent.record.losses}-${opponent.record.draws}`, + sub: total > 0 ? `${t("winRate")} ${winRate}%` : "-", + }, + { + label: t("age"), + value: String(opponent.age), + sub: `KO ${koStats.age}`, + }, + { + label: t("height"), + value: opponent.height, + sub: `KO ${stripUnit(koStats.height)}`, + }, + { + label: t("reach"), + value: opponent.reach, + sub: `KO ${stripUnit(koStats.reach)}`, + }, + ]; - {/* 국적 + 랭킹 */} -
- - {opponent.country} + return ( + + {/* 상단 배경 글로우 */} +
+ +
+ {/* 후보 순위 배지 */} + + {t("candidate", { number: index + 1 })} - - - #{opponent.fightMatrixRank} + + {/* 프로필 이미지 */} +
+ {opponent.imageUrl && + !opponent.imageUrl.includes("placeholder") ? ( + {opponent.name[lang]} + ) : ( + + )} +
+ + {/* 이름 */} +

+ {opponent.name[lang]} +

+ + {/* 국적 + 랭킹 */} +
+ + {opponent.country} + + + + FightMatrix #{opponent.fightMatrixRank} + +
+ + {/* 스타일 태그 */} + + {opponent.fightingStyle[lang]} -
- {/* 스타일 태그 */} - - {opponent.fightingStyle[lang]} - - - {/* 전적 */} -

- {opponent.record.wins}W - {opponent.record.losses}L -{" "} - {opponent.record.draws}D -

-
- - ))} + {/* 미니 비교 그리드 (상대 값 + KO 병기) */} +
+ {miniStats.map((stat, i) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+

+ {stat.sub} +

+
+ ))} +
+ + {/* AI 승률 바 */} +
+

+ {t("winProbability")} +

+
+ + {t("koName")} {koProb}% + + + {100 - koProb}% + +
+
+
+
+
+
+
+ + ); + })}
{/* 하단 CTA */} diff --git a/src/components/predictions/ConfirmedFightCard.tsx b/src/components/predictions/ConfirmedFightCard.tsx index 4240ba7..e0f4eb8 100644 --- a/src/components/predictions/ConfirmedFightCard.tsx +++ b/src/components/predictions/ConfirmedFightCard.tsx @@ -4,19 +4,21 @@ import { useTranslations } from "next-intl"; import { useInView } from "@/hooks/useInView"; import { formatEventDate, getKstDaysUntil } from "@/lib/date-utils"; +import type { KoComparisonStats } from "@/lib/ko-stats"; import type { ConfirmedFight } from "@/types/prediction"; interface ConfirmedFightCardProps { fight: ConfirmedFight; // 확정된 다음 경기 정보 + koStats: KoComparisonStats; // Tale of the Tape 비교용 고석현 측 데이터 locale: string; // "ko" | "en" } -// 상대 이미지 없음/placeholder URL일 때 표시할 실루엣 +// 상대 이미지 없음/placeholder URL일 때 표시할 실루엣 (다크 배경용) function FighterPlaceholder() { return ( -
+
@@ -34,13 +36,16 @@ const getDdayLabel = (dateStr: string): string | null => { }; /** - * @description 확정된 다음 경기(고석현 vs 상대)를 카드로 표시. 메인 프리뷰·예측 상세 페이지 공용. - * D-day 카운트다운, 로케일별 날짜, 상대 전적/스타일/국적을 함께 노출. + * @description 확정된 다음 경기(고석현 vs 상대)를 UFC 대전 카드 스타일의 Tale of the Tape로 표시. + * 메인 프리뷰·예측 상세 페이지 공용. 페이스오프(사진·D-day) 아래에 전적/나이/신장·체중/리치/스타일을 양측 비교. + * 상대의 신체 스펙은 Gemini 보강값이라 없을 수 있으며, 없는 항목의 행은 렌더하지 않음. * @param props.fight - 확정 경기 정보 (상대/이벤트/날짜/장소) + * @param props.koStats - 고석현 측 비교 데이터 (전적/나이/신장/체중/리치/스타일) * @param props.locale - "ko" | "en" */ export default function ConfirmedFightCard({ fight, + koStats, locale, }: ConfirmedFightCardProps) { const t = useTranslations("predictions"); @@ -48,9 +53,45 @@ export default function ConfirmedFightCard({ const lang = locale === "ko" ? "ko" : "en"; const dday = getDdayLabel(fight.date); - const { record, fightingStyle, country } = fight.opponent; + const { record, fightingStyle, age, height, weight, reach } = fight.opponent; const hasRealImage = fight.opponent.imageUrl && !fight.opponent.imageUrl.includes("placeholder"); + const hasRecord = record.wins + record.losses + record.draws > 0; + + // 비교 테이블 행 구성 — 상대 데이터가 없는 항목은 제외 (구버전 확정 데이터 대응) + const rows = [ + hasRecord && { + label: t("record"), + left: koStats.record, + right: `${record.wins}-${record.losses}-${record.draws}`, + }, + age && { + label: t("age"), + left: String(koStats.age), + right: String(age), + }, + height && + weight && { + label: t("heightWeight"), + left: `${koStats.height} / ${koStats.weight}`, + right: `${height} / ${weight}`, + }, + reach && { + label: t("reach"), + left: koStats.reach, + right: reach, + }, + fightingStyle[lang] && { + label: t("style"), + left: koStats.style[lang], + right: fightingStyle[lang], + }, + fight.opponent.country && { + label: t("country"), + left: koStats.country[lang], + right: fight.opponent.country, + }, + ].filter(Boolean) as { label: string; left: string; right: string }[]; return (
@@ -63,81 +104,163 @@ export default function ConfirmedFightCard({ transition: "opacity 0.5s ease, transform 0.5s ease", }} > -
+
{t("confirmed")} - {dday && ( - - {dday} - - )}

{t("koName")} {t("vs")} {fight.opponent.name[lang]}

- {/* 매치업 카드 */} + {/* 대전 카드 (Tale of the Tape) */}
-
-
- {t("koName")} -
- {t("vs")} -
- {hasRealImage ? ( - {fight.opponent.name[lang]} - ) : ( - + {/* 이벤트 정보 스트립 */} +
+

+ {fight.event} +

+
+ + + + + {formatEventDate(fight.date, locale) || fight.date} + + {fight.location[lang] && ( + + + + + + {fight.location[lang]} + )}
- {/* 상대 부가정보 (전적/스타일/국적) */} -
- {(record.wins > 0 || record.losses > 0 || record.draws > 0) && ( - - {record.wins}-{record.losses}-{record.draws} - - )} - {fightingStyle[lang] && ( - - {fightingStyle[lang]} - - )} - {country && ( - - {country} - - )} + {/* 페이스오프 */} +
+ {/* 배경 글로우 효과 */} +
+
+
+
+ +
+ {/* 왼쪽: 고석현 */} +
+
+ {t("koName")} +
+

+ {t("koName")} +

+ + KOR + +
+ + {/* 가운데: D-day + VS */} +
+ {dday && ( + + {dday} + + )} + + {t("vs")} + +
+ + {/* 오른쪽: 상대 */} +
+
+ {hasRealImage ? ( + {fight.opponent.name[lang]} + ) : ( + + )} +
+

+ {fight.opponent.name[lang]} +

+ + {fight.opponent.country || "-"} + +
+
-

- {t("confirmedEvent")}: {fight.event} -

-

- {t("confirmedDate")}:{" "} - {formatEventDate(fight.date, locale) || fight.date} -

-

- {t("confirmedLocation")}: {fight.location[lang]} -

+ {/* 비교 테이블 (Tale of the Tape) */} + {rows.length > 0 && ( +
+

+ Tale of the Tape +

+
+ {rows.map((row, i) => ( +
+ + {row.left} + + + {row.label} + + + {row.right} + +
+ ))} +
+
+ )}
); diff --git a/src/components/predictions/FighterComparison.tsx b/src/components/predictions/FighterComparison.tsx index 81b16f2..f18a614 100644 --- a/src/components/predictions/FighterComparison.tsx +++ b/src/components/predictions/FighterComparison.tsx @@ -2,21 +2,13 @@ import { useTranslations } from "next-intl"; +import type { KoComparisonStats } from "@/lib/ko-stats"; import type { OpponentPrediction } from "@/types/prediction"; interface FighterComparisonProps { opponent: OpponentPrediction; locale: string; - koStats: { - record: string; - age: number; - height: string; - weight: string; - reach: string; - style: { ko: string; en: string }; - fightMatrixRank: number; - lastFightDate?: string; - }; + koStats: KoComparisonStats; } /** 다양한 날짜 형식을 "약 N개월 전" / "~N months ago" 형식으로 표시 */ @@ -179,12 +171,12 @@ export default function FighterComparison({ {/* 비교 테이블 */}
-
+
{rows.map((row, i) => (
diff --git a/src/components/predictions/PredictionDetail.tsx b/src/components/predictions/PredictionDetail.tsx index 902f500..b01d22b 100644 --- a/src/components/predictions/PredictionDetail.tsx +++ b/src/components/predictions/PredictionDetail.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { useInView } from "@/hooks/useInView"; import { formatKstLongDate } from "@/lib/date-utils"; +import type { KoComparisonStats } from "@/lib/ko-stats"; import type { PredictionData } from "@/types/prediction"; import ConfirmedFightCard from "./ConfirmedFightCard"; @@ -21,16 +22,7 @@ interface OpponentVideo { interface PredictionDetailProps { predictions: PredictionData; - koStats: { - record: string; - age: number; - height: string; - weight: string; - reach: string; - style: { ko: string; en: string }; - fightMatrixRank: number; - lastFightDate?: string; - }; + koStats: KoComparisonStats; locale: string; } @@ -81,6 +73,7 @@ export default function PredictionDetail({
diff --git a/src/lib/crawl/confirmed-fight.ts b/src/lib/crawl/confirmed-fight.ts index 196c8ad..05eba52 100644 --- a/src/lib/crawl/confirmed-fight.ts +++ b/src/lib/crawl/confirmed-fight.ts @@ -41,8 +41,9 @@ const findKoMatch = ( /** * @description 크롤된 UFC 일정에서 고석현의 확정 경기를 자동 감지해 ConfirmedFight 생성. - * 상대·이벤트가 기존 확정과 동일하면 Gemini 재호출 없이 부가정보를 재사용(비용 절감), - * 신규/변경 시에만 Gemini로 한국어명·국적·스타일 보강. 고석현 매치가 없으면 null. + * 상대·이벤트가 기존 확정과 동일하고 신체 스펙까지 보강돼 있으면 Gemini 재호출 없이 재사용(비용 절감), + * 신규/변경 또는 스펙 미보강(구버전 데이터)이면 Gemini로 한국어명·국적·스타일·나이·신장/체중/리치·전적을 보강. + * 고석현 매치가 없으면 null. * @param events - 크롤된 UFC 이벤트 배열 * @param existing - DB에 저장된 기존 confirmedFight (재사용 판단용, 선택) * @returns 확정 경기 정보 또는 null @@ -55,35 +56,52 @@ export async function detectKoConfirmedFight( if (!match) return null; const { event, opponent } = match; - // 기존 확정과 상대·대회가 같으면 Gemini 재호출 없이 재사용 (이미지/날짜/장소만 최신화) + // 기존 확정과 상대·대회가 같은지 (Gemini 재호출 여부 판단) const sameAsExisting = existing && existing.opponent.name.en.toLowerCase() === opponent.name.toLowerCase() && existing.event === event.name; - if (sameAsExisting) { + // 기존 보강 정보를 유지한 채 이미지/전적/날짜/장소만 최신화. + // 크롤에 전적이 없으면(0-0-0) 기존 전적을 유지해 다운그레이드 방지. + const reuseExisting = (): ConfirmedFight => { + const crawled = parseRecord(opponent.record); + const hasCrawledRecord = crawled.wins + crawled.losses + crawled.draws > 0; return { - ...existing, + ...existing!, opponent: { - ...existing.opponent, - imageUrl: opponent.imageUrl || existing.opponent.imageUrl, - record: parseRecord(opponent.record), + ...existing!.opponent, + imageUrl: opponent.imageUrl || existing!.opponent.imageUrl, + record: hasCrawledRecord ? crawled : existing!.opponent.record, }, date: event.date, location: event.location, event: event.name, }; + }; + + // 신체 스펙(height)까지 이미 보강된 기존 데이터면 Gemini 재호출 없이 재사용. + // height가 없으면 Tale of the Tape 도입 이전 데이터이므로 아래 enrich 경로로 백필. + if (sameAsExisting && existing.opponent.height) { + return reuseExisting(); } - // 신규/변경된 상대 → Gemini로 한국어명·국적·스타일 보강 (실패 시 최소 정보 폴백) + // 신규/변경된 상대 → Gemini로 한국어명·국적·스타일·신체 스펙·전적 보강 let enrich: { nameKo: string; country: string; fightingStyle: { ko: string; en: string }; + age?: number; + height?: string; + weight?: string; + reach?: string; + record?: string; }; try { enrich = await analyzeConfirmedOpponent(opponent.name, opponent.record); } catch { + // 보강 실패: 기존 데이터가 있으면 그대로 유지, 없으면 최소 정보 폴백 + if (sameAsExisting) return reuseExisting(); enrich = { nameKo: opponent.name, country: "", @@ -91,13 +109,25 @@ export async function detectKoConfirmedFight( }; } + // 전적은 크롤값 우선, 크롤에 없으면(0-0-0) Gemini 보강값 폴백 + const crawledRecord = parseRecord(opponent.record); + const hasCrawledRecord = + crawledRecord.wins + crawledRecord.losses + crawledRecord.draws > 0; + return { opponent: { name: { ko: enrich.nameKo, en: opponent.name }, - imageUrl: opponent.imageUrl ?? "", + // 백필 경로(sameAsExisting)에서 크롤 이미지가 비어도 기존 이미지를 유지 + imageUrl: + opponent.imageUrl || + (sameAsExisting ? existing!.opponent.imageUrl : ""), country: enrich.country, - record: parseRecord(opponent.record), + record: hasCrawledRecord ? crawledRecord : parseRecord(enrich.record), fightingStyle: enrich.fightingStyle, + age: enrich.age, + height: enrich.height, + weight: enrich.weight, + reach: enrich.reach, }, date: event.date, location: event.location, diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 80efcbe..e6e591d 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -246,11 +246,11 @@ IMPORTANT: Provide fightAnalysis in both Korean and English. Be specific about t } /** - * @description 고석현의 확정된 다음 상대에 대한 부가정보(한국어명·국적·스타일) 보강. - * 확정 경기 카드는 상대의 ko/en 이름·국적·파이팅 스타일을 요구하는데 UFC 일정 크롤에는 없어 Gemini로 보완. + * @description 고석현의 확정된 다음 상대에 대한 부가정보(한국어명·국적·스타일·신체 스펙) 보강. + * 확정 경기 카드의 Tale of the Tape 비교는 상대의 나이·신장·체중·리치까지 요구하는데 UFC 일정 크롤에는 없어 Gemini로 보완. * @param nameEn - 상대 영문 이름 (UFC 일정 표기 그대로) - * @param record - 상대 전적 문자열 (예: "22-7-0", 선택) - * @returns 한국어명·국적(영문)·파이팅 스타일(한/영) + * @param record - 상대 전적 문자열 (예: "22-7-0", 선택). 크롤에 없으면 Gemini 응답의 record가 폴백으로 쓰임 + * @returns 한국어명·국적(영문)·파이팅 스타일(한/영)·나이·신장/체중/리치(메트릭)·전적 * @throws Gemini 응답이 유효하지 않을 때 */ export async function analyzeConfirmedOpponent( @@ -260,11 +260,19 @@ export async function analyzeConfirmedOpponent( nameKo: string; country: string; fightingStyle: { ko: string; en: string }; + age: number; + height: string; + weight: string; + reach: string; + record: string; }> { const prompt = `The UFC fighter "${nameEn}"${record ? ` (record ${record})` : ""} is Ko Seokhyeon's confirmed next opponent. Based on your knowledge of this fighter, provide: 1. nameKo: Korean phonetic transliteration of the fighter's name (한국어) 2. country: the fighter's country in English (e.g. "USA", "Brazil", "Russia") 3. fightingStyle: a short 2-4 word description of their fighting style, in both Korean and English +4. age: the fighter's current age (number) +5. height / weight / reach: in METRIC units — height in cm (e.g. "182.9cm"), weight in kg (e.g. "77.1kg"), reach in cm (e.g. "188cm") +6. record: professional MMA record as "W-L-D" (e.g. "22-7-0")${record ? ` — the given record above is authoritative, repeat it` : ""} If you are unsure about the fighter, give a reasonable best-effort answer (do not leave fields empty).`; @@ -285,8 +293,34 @@ If you are unsure about the fighter, give a reasonable best-effort answer (do no }, required: ["ko", "en"], }, + age: { type: SchemaType.NUMBER }, + height: { + type: SchemaType.STRING, + description: 'Height in cm, e.g. "182.9cm"', + }, + weight: { + type: SchemaType.STRING, + description: 'Weight in kg, e.g. "77.1kg"', + }, + reach: { + type: SchemaType.STRING, + description: 'Reach in cm, e.g. "188cm"', + }, + record: { + type: SchemaType.STRING, + description: 'MMA record "W-L-D", e.g. "22-7-0"', + }, }, - required: ["nameKo", "country", "fightingStyle"], + required: [ + "nameKo", + "country", + "fightingStyle", + "age", + "height", + "weight", + "reach", + "record", + ], }, }, }); @@ -295,6 +329,11 @@ If you are unsure about the fighter, give a reasonable best-effort answer (do no nameKo: string; country: string; fightingStyle: { ko: string; en: string }; + age: number; + height: string; + weight: string; + reach: string; + record: string; }; if (!parsed.nameKo || !parsed.fightingStyle) { diff --git a/src/lib/ko-stats.ts b/src/lib/ko-stats.ts new file mode 100644 index 0000000..0a7ae93 --- /dev/null +++ b/src/lib/ko-stats.ts @@ -0,0 +1,56 @@ +import type { FighterStats } from "@/types/fighter"; + +import { differenceInYears } from "date-fns"; + +// 크롤된 스탯은 임페리얼 표기(5'10", 170 lbs)라 비교 UI에는 메트릭 고정값을 사용 +export const KO_PROFILE = { + birthDate: "1993-09-24", + height: "177.8cm", + weight: "77.1kg", + reach: "180.3cm", + style: { ko: "유도 / 삼보", en: "Judo / Sambo" }, + country: { ko: "대한민국", en: "South Korea" }, +} as const; + +// 고석현 vs 상대 비교(Tale of the Tape) UI에서 공통으로 쓰는 고석현 측 데이터 +export interface KoComparisonStats { + record: string; + age: number; + height: string; + weight: string; + reach: string; + style: { ko: string; en: string }; + country: { ko: string; en: string }; + fightMatrixRank: number; + lastFightDate?: string; +} + +/** + * @description 크롤된 선수 스탯에서 비교 UI용 고석현 데이터를 구성. 메인·예측 페이지가 공유. + * @param stats - 크롤된 고석현 스탯 (전적/외부 랭킹/전적 히스토리) + * @param fallbackRank - externalRankings에 FightMatrix가 없을 때 사용할 랭킹 + * @param fallbackLastFightDate - fightHistory가 비어있을 때 사용할 최근 경기일 + * @returns 비교 UI용 고석현 스탯 + */ +export function buildKoComparisonStats( + stats: FighterStats, + fallbackRank: number, + fallbackLastFightDate?: string +): KoComparisonStats { + const fightMatrixRank = + stats.externalRankings?.find((r) => r.site === "FightMatrix")?.rank ?? + fallbackRank; + + return { + record: `${stats.record.wins}-${stats.record.losses}-${stats.record.draws}`, + age: differenceInYears(new Date(), new Date(KO_PROFILE.birthDate)), + height: KO_PROFILE.height, + weight: KO_PROFILE.weight, + reach: KO_PROFILE.reach, + style: KO_PROFILE.style, + country: KO_PROFILE.country, + fightMatrixRank, + // stats.fightHistory가 더 신뢰할 수 있는 ISO 형식이므로 우선 사용 + lastFightDate: stats.fightHistory?.[0]?.date || fallbackLastFightDate, + }; +} diff --git a/src/messages/en.json b/src/messages/en.json index 7721cfe..fad193f 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -116,8 +116,11 @@ "record": "Record", "age": "Age", "heightWeight": "Height / Weight", + "height": "Height", "reach": "Reach", "style": "Style", + "country": "Country", + "winRate": "Win rate", "winProbability": "AI Win Probability", "matchReason": "Match Likelihood", "fightAnalysis": "Fight Analysis", diff --git a/src/messages/ko.json b/src/messages/ko.json index c7f24ce..70b86bb 100644 --- a/src/messages/ko.json +++ b/src/messages/ko.json @@ -116,8 +116,11 @@ "record": "전적", "age": "나이", "heightWeight": "신장 / 체중", + "height": "신장", "reach": "리치", "style": "스타일", + "country": "국가", + "winRate": "승률", "winProbability": "AI 승률 예측", "matchReason": "매칭 가능성", "fightAnalysis": "승부 분석", diff --git a/src/types/prediction.ts b/src/types/prediction.ts index 068fbad..49b0228 100644 --- a/src/types/prediction.ts +++ b/src/types/prediction.ts @@ -23,6 +23,14 @@ export interface ConfirmedFight { country: string; record: { wins: number; losses: number; draws: number }; fightingStyle: { ko: string; en: string }; + /** 나이 (Gemini 보강값, 구버전 데이터엔 없을 수 있음) */ + age?: number; + /** 신장, 메트릭 표기 (예: "183cm") */ + height?: string; + /** 체중, 메트릭 표기 (예: "77.1kg") */ + weight?: string; + /** 리치, 메트릭 표기 (예: "183cm") */ + reach?: string; }; date: string; // ISO date (e.g. "2026-06-21") location: { ko: string; en: string };