한국-멕시코전 전용 공유 카드 + 앱 배너 이미지
parent
dc262009f9
commit
65e7c7571c
|
|
@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from .config import settings
|
||||
from .database import init_db
|
||||
from .routers import admin, leaderboard, matches, predictions, visits
|
||||
from .routers import admin, leaderboard, matches, predictions, share, visits
|
||||
from .scoring import load_scoring_data
|
||||
from .seed import seed_if_empty
|
||||
|
||||
|
|
@ -45,6 +45,8 @@ app.include_router(predictions.router)
|
|||
app.include_router(leaderboard.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(visits.router)
|
||||
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
|
||||
app.include_router(share.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
"""공유 미리보기(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
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..config import settings
|
||||
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("/match/{match_id}", response_class=HTMLResponse)
|
||||
async def match_share(match_id: str) -> 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
|
||||
|
||||
la, lb = _team_label(a), _team_label(b)
|
||||
if la and lb:
|
||||
title = f"TriplePick 2026 — {la} vs {lb} AI 승부예측"
|
||||
else:
|
||||
title = "TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측"
|
||||
desc = "AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
|
||||
|
||||
origin = settings.public_origin.rstrip("/")
|
||||
page_url = f"{origin}/match/{html.escape(match_id)}"
|
||||
img_url = f"{origin}{image}"
|
||||
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)
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
# 공유 크롤러(카카오톡·트위터·페북 등) 판별 → /match/:id 만 백엔드 OG 프리렌더로.
|
||||
# 일반 사용자(JS 실행)는 0 → 기존 SPA 그대로.
|
||||
map $http_user_agent $is_share_crawler {
|
||||
default 0;
|
||||
"~*(kakaotalk|facebookexternalhit|facebot|twitterbot|slackbot|discordbot|telegrambot|whatsapp|line\\b|skypeuripreview|pinterest|redditbot|googlebot|bingbot|daumoa|yeti)" 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
|
@ -13,6 +20,21 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 경기 공유 링크: 크롤러면 백엔드 OG 프리렌더, 사람이면 SPA.
|
||||
# (418 → 내부 named location 으로 우회: if + try_files 충돌 회피 정석 패턴)
|
||||
location /match/ {
|
||||
error_page 418 = @og_prerender;
|
||||
if ($is_share_crawler) { return 418; }
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
location @og_prerender {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA 폴백 — 클라이언트 라우팅(/match/:id 등)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# 경기별 공유 썸네일 (OG 이미지)
|
||||
|
||||
카카오톡·트위터 등에서 특정 경기 링크(`/match/:id`)를 공유할 때 뜨는 미리보기
|
||||
이미지를 여기에 둔다. 미등록 경기는 기본 썸네일(`assets/bi/og-image.png`)로 폴백.
|
||||
|
||||
## 용도 두 가지
|
||||
|
||||
- **앱 안 배너**(매치 상세 페이지): 세로/원본 이미지. `pages/MatchDetail.tsx` 에서 직접 참조.
|
||||
예) `kor_mex.png` (세로 816×1456)
|
||||
- **공유 카드**(카톡·트위터 미리보기): 가로 **1200 × 630** 합성본. `share.py` 의 `OG_IMAGES` 에 등록.
|
||||
예) `kor_mex_card.png`
|
||||
|
||||
세로 원본만 있으면 카드용 가로본은 아래 스크립트로 자동 합성(원본을 가운데 두고
|
||||
배경은 블러로 채움 → 잘림 없음):
|
||||
```python
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
W, H = 1200, 630
|
||||
src = Image.open("kor_mex.png").convert("RGB"); sw, sh = src.size
|
||||
s = max(W/sw, H/sh); bw, bh = int(sw*s)+1, int(sh*s)+1
|
||||
bg = src.resize((bw,bh), Image.LANCZOS).crop(((bw-W)//2,(bh-H)//2,(bw-W)//2+W,(bh-H)//2+H))
|
||||
bg = ImageEnhance.Brightness(bg.filter(ImageFilter.GaussianBlur(22))).enhance(0.55)
|
||||
fw = int(sw*(H/sh)); bg.paste(src.resize((fw,H), Image.LANCZOS), ((W-fw)//2, 0))
|
||||
bg.save("kor_mex_card.png", "PNG")
|
||||
```
|
||||
|
||||
## 적용 방법 (예: 한국-멕시코전)
|
||||
|
||||
1. 세로 원본을 이 폴더에 넣는다 → `kor_mex.png` (앱 배너용)
|
||||
2. 위 스크립트로 가로 카드 합성 → `kor_mex_card.png` (공유 카드용)
|
||||
3. `backend/app/routers/share.py` 의 `OG_IMAGES` 에 등록:
|
||||
```python
|
||||
OG_IMAGES = {
|
||||
frozenset({"KOR", "MEX"}): "kor_mex_card.png",
|
||||
}
|
||||
```
|
||||
4. 배포: 서버에서 `git pull && docker compose up --build -d`
|
||||
|
||||
## 동작 원리
|
||||
|
||||
- nginx 가 공유 크롤러(UA)만 `/match/:id` → 백엔드 OG 프리렌더로 보낸다.
|
||||
- 백엔드가 경기 ID(`{조}_{팀A}_{팀B}_{날짜}`)에서 두 팀 코드를 뽑아
|
||||
`OG_IMAGES` 에 있으면 해당 이미지를, 없으면 기본 썸네일을 단 HTML 을 반환.
|
||||
- 일반 사용자는 영향 없이 기존 SPA 그대로.
|
||||
|
||||
팀 코드 순서는 무관(`KOR_MEX` = `MEX_KOR`). 한국-멕시코 경기 ID 는
|
||||
`A_MEX_KOR_20260619`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -59,6 +59,13 @@ export default function MatchDetail() {
|
|||
const hook = lang === "en" ? t.hook(aShort, bShort) : match.hookText;
|
||||
const url = matchUrl(match.matchId);
|
||||
|
||||
// 특정 경기 전용 비주얼 배너(코드쌍 → 이미지). 등록된 경기에만 노출.
|
||||
const codes = new Set([match.teamA.code, match.teamB.code]);
|
||||
const matchBanner =
|
||||
codes.has("KOR") && codes.has("MEX")
|
||||
? "/assets/og/kor_mex.png"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<Hero
|
||||
|
|
@ -81,6 +88,18 @@ export default function MatchDetail() {
|
|||
<span className="text-[var(--green)]">⫽</span>
|
||||
</h1>
|
||||
|
||||
{/* 경기 전용 비주얼 배너 (한국-멕시코 등 등록된 경기만) */}
|
||||
{matchBanner && (
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-white/10 shadow-[0_12px_32px_rgba(0,0,0,0.45)]">
|
||||
<img
|
||||
src={matchBanner}
|
||||
alt={`${aShort} vs ${bShort}`}
|
||||
loading="lazy"
|
||||
className="block max-h-[440px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MatchupHUD match={match} lang={lang} />
|
||||
<Arena
|
||||
match={match}
|
||||
|
|
|
|||
Loading…
Reference in New Issue