o2o-triple-pick/backend/app/routers/share.py
jwkim bb930cc59f 응원가 공유·플레이어 문구 '오늘의' → 제작일 표기 (예: 8월 28일 OO 응원가)
songs API에 dateKst(경기일) 추가, OG 공유 제목·프론트 shareText·artist에 적용
— 매일 갱신되는 콘텐츠임을 드러낸다

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:21:24 +09:00

192 lines
7.5 KiB
Python

"""공유 미리보기(OG) 프리렌더 — 카카오톡·트위터 등 크롤러 전용.
SPA(index.html)의 og:image 는 정적이라 모든 /match/:id 공유가 같은 썸네일로
나온다(크롤러는 JS 미실행). 그래서 nginx 가 '크롤러 UA' 의 /match/:id 요청만
이 라우트로 보내고, 여기서 경기별 og:image·og:title 을 박은 HTML 을 반환한다.
일반 사용자는 nginx 가 그대로 SPA 로 보내므로 영향 없음.
경기별 커스텀 이미지는 OG_IMAGES 에 등록된 매치업만 적용되고, 나머지는
기본 썸네일로 폴백한다. 이미지 파일은 frontend/public/assets/og/ 에 둔다.
"""
from __future__ import annotations
import html
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Match, Song
from ..schedule_data import TEAMS
router = APIRouter()
# 경기별 커스텀 OG 이미지: 두 팀 코드(순서무관) → /assets/og/ 하위 파일명.
# 파일을 frontend/public/assets/og/ 에 두고 아래에 등록하면 적용된다.
# 미등록 매치업은 DEFAULT_OG 로 폴백.
# 예) frozenset({"KOR", "MEX"}): "kor_mex.png",
OG_IMAGES: dict[frozenset[str], str] = {
# 한국-멕시코전 공유 카드 — 가로 1200x630 합성본(앱 배너는 세로 원본 kor_mex.png).
frozenset({"KOR", "MEX"}): "kor_mex_card.png",
}
DEFAULT_OG = "/assets/bi/og-image.png"
OG_DIR = "/assets/og/"
# 전체 일정(타조 포함) 팀 한글명 — 타이틀용. schedule_data.TEAMS 우선, 없으면 코드.
_KOR_NAME = {code: t["shortName"] for code, t in TEAMS.items()}
def _parse_codes(match_id: str) -> tuple[str | None, str | None]:
"""경기 ID '{조}_{팀A}_{팀B}_{YYYYMMDD}' 에서 두 팀 코드 추출."""
parts = match_id.split("_")
if len(parts) >= 3:
return parts[1], parts[2]
return None, None
def _team_label(code: str | None) -> str:
if not code:
return ""
return _KOR_NAME.get(code, code)
@router.get("/song/{match_id}/{team_code}", response_class=HTMLResponse)
async def song_share(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> HTMLResponse:
"""응원가 공유 OG — '8월 28일 OO 응원가' 제목 + Suno 앨범아트 썸네일.
제목의 날짜는 제작일(경기일 KST) — 매일 갱신되는 콘텐츠임을 드러낸다.
"""
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
team = (s.team_name if s else "") or _team_label(team_code) or team_code
song_title = (s.title if s else "") or f"{team} 응원가"
day = f"{s.date_kst.month}{s.date_kst.day}" if s and s.date_kst else "오늘의"
title = f"{day} {team} 응원가 — {song_title}"
desc = (
"AI가 경기 당일(순위·선발 라인업·전날 결과)을 반영해 매일 새로 만드는 응원가. "
"듣고 나서 GPT·Claude·Gemini와 승부예측도 겨뤄보세요!"
)
origin = settings.public_origin.rstrip("/")
# 썸네일: 합성 커버(Suno 아트 + 팀 로고, 1200x630) → 곡 없으면 기본 카드
if s and s.tracks:
img_url = (
f"{origin}/api/songs/cover/{html.escape(match_id)}/"
f"{html.escape(team_code)}?v={(s.task_id or '')[:8]}"
)
else:
img_url = f"{origin}{DEFAULT_OG}?v=2"
page_url = f"{origin}/song/{html.escape(match_id)}/{html.escape(team_code)}"
t = html.escape(title)
d = html.escape(desc)
page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>{t}</title>
<meta name="description" content="{d}" />
<meta property="og:type" content="music.song" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{d}" />
<meta property="og:url" content="{page_url}" />
<meta property="og:image" content="{img_url}" />
<meta property="og:image:secure_url" content="{img_url}" />
<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{t}" />
<meta name="twitter:description" content="{d}" />
<meta name="twitter:image" content="{img_url}" />
<link rel="canonical" href="{page_url}" />
</head>
<body><a href="{page_url}">TriplePick — {t}</a></body>
</html>"""
return HTMLResponse(page)
@router.get("/match/{match_id}", response_class=HTMLResponse)
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
a, b = _parse_codes(match_id)
image = DEFAULT_OG
is_custom = False
if a and b:
key = frozenset({a, b})
if key in OG_IMAGES:
image = OG_DIR + OG_IMAGES[key]
is_custom = True
# 팀명: DB 의 경기 행이 있으면 한글 short 명(클럽 리그 포함),
# 없으면 월드컵 정적 테이블 → 코드 순으로 폴백.
m = (
await db.execute(select(Match).where(Match.match_id == match_id))
).scalar_one_or_none()
if m:
la, lb = m.team_a_short, m.team_b_short
a, b = m.team_a_code, m.team_b_code
else:
la, lb = _team_label(a), _team_label(b)
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일).
if b == "KOR" and a != "KOR":
la, lb = lb, la
league_ko = {"kbo": "KBO", "mlb": "MLB", "mls": "MLS"}.get(m.league if m else "", "")
if la and lb:
tag = f"{league_ko} " if league_ko else ""
title = f"TriplePick — {tag}{la} vs {lb} AI 승부예측"
else:
title = "TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)"
desc = (
"GPT·Claude·Gemini 3대 AI가 이 경기를 서로 다르게 예측합니다. "
"당신의 픽을 찍고 AI와 겨뤄보세요."
)
origin = settings.public_origin.rstrip("/")
page_url = f"{origin}/match/{html.escape(match_id)}"
# ?v= 캐시버스터: 메신저가 과거 실패/구버전 썸네일을 캐시한 경우 재수집 유도
img_url = f"{origin}{image}?v=2"
t = html.escape(title)
d = html.escape(desc)
# 기본 썸네일만 규격이 1200x630 으로 확정 → width/height 명시.
# 커스텀 이미지는 규격이 제각각이라 태그를 빼고 크롤러가 직접 측정하게 둔다.
dims = (
""
if is_custom
else '<meta property="og:image:width" content="1200" />\n'
'<meta property="og:image:height" content="630" />\n'
)
page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>{t}</title>
<meta name="description" content="{d}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{d}" />
<meta property="og:url" content="{page_url}" />
<meta property="og:image" content="{img_url}" />
<meta property="og:image:secure_url" content="{img_url}" />
<meta property="og:image:type" content="image/png" />
{dims}<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{t}" />
<meta name="twitter:description" content="{d}" />
<meta name="twitter:image" content="{img_url}" />
<link rel="canonical" href="{page_url}" />
</head>
<body><a href="{page_url}">TriplePick</a></body>
</html>"""
return HTMLResponse(page)