diff --git a/backend/app/main.py b/backend/app/main.py index ef50f77..e2e5ce8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py new file mode 100644 index 0000000..24a175e --- /dev/null +++ b/backend/app/routers/share.py @@ -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 '\n' + '\n' + ) + + page = f""" + + + +{t} + + + + + + + + + +{dims} + + + + + + +TriplePick +""" + return HTMLResponse(page) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c7ee731..bcc814b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/public/assets/og/README.md b/frontend/public/assets/og/README.md new file mode 100644 index 0000000..660be3a --- /dev/null +++ b/frontend/public/assets/og/README.md @@ -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`. diff --git a/frontend/public/assets/og/kor_mex.png b/frontend/public/assets/og/kor_mex.png new file mode 100644 index 0000000..9ba57da Binary files /dev/null and b/frontend/public/assets/og/kor_mex.png differ diff --git a/frontend/public/assets/og/kor_mex_card.png b/frontend/public/assets/og/kor_mex_card.png new file mode 100644 index 0000000..696dc54 Binary files /dev/null and b/frontend/public/assets/og/kor_mex_card.png differ diff --git a/frontend/src/pages/MatchDetail.tsx b/frontend/src/pages/MatchDetail.tsx index 3158d62..a746fd0 100644 --- a/frontend/src/pages/MatchDetail.tsx +++ b/frontend/src/pages/MatchDetail.tsx @@ -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 (
⫽ + {/* 경기 전용 비주얼 배너 (한국-멕시코 등 등록된 경기만) */} + {matchBanner && ( +
+ {`${aShort} +
+ )} +