fix: 페이스북 공유하기 수정

This commit is contained in:
김성경 2026-08-21 08:48:08 +09:00
parent 1b982b6ae1
commit 9d40313fbb
4 changed files with 175 additions and 21 deletions

View File

@ -70,6 +70,7 @@ PROJECT_VERSION=0.1.0 # 프로젝트 버전
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png)
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)

View File

@ -58,7 +58,12 @@ from app.video.schemas.video_schema import (
VideoRenderData,
VideoThumbnailItem,
)
from app.video.services.share_page import build_video_share_html, get_video_share_data
from app.video.services.share_page import (
build_video_share_html,
get_video_share_data,
resolve_frontend_base_url,
resolve_share_url,
)
from app.video.worker.video_task import download_and_upload_video_to_blob
@ -1113,11 +1118,18 @@ async def get_video_share_page(
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
share_url = str(request.url).split("?", maxsplit=1)[0]
share_url = resolve_share_url(
request.headers,
str(request.url).split("?", maxsplit=1)[0],
prj_settings.SHARE_API_BASE_URL,
)
html = build_video_share_html(
share_data,
share_url=share_url,
frontend_base_url=prj_settings.SHARE_FRONTEND_URL,
frontend_base_url=resolve_frontend_base_url(
request.headers,
prj_settings.SHARE_FRONTEND_URL,
),
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return HTMLResponse(

View File

@ -1,5 +1,6 @@
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass
from html import escape
from urllib.parse import urlsplit, urlunsplit
@ -22,6 +23,8 @@ class VideoShareData:
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
async def get_video_share_data(
@ -33,6 +36,8 @@ async def get_video_share_data(
select(
Video.id,
Video.poster_url,
Video.title,
Video.description,
Project.store_name,
Project.region,
)
@ -53,6 +58,8 @@ async def get_video_share_data(
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region,
title=row.title,
description=row.description,
)
@ -73,19 +80,14 @@ def build_video_share_html(
)
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
store_name = _normalise_text(data.store_name, "ADO2 영상")
region = _normalise_text(data.region, "")
title = f"{store_name} | ADO2"
description = (
f"{region} · {store_name} 어떤 내용이 들어가야할지 정해야 합니다. 기존 영상 업로드시에 생성되는 내용을 사용하려면 db에 저장하고 업로드 할때마다 변경되는 부분을 전부 수정해야 합니다."
if region
else "ADO2 AI 마케팅 영상"
)
title = _share_title(data.title, data.store_name)
description = _share_description(data.description)
canonical_tags = _canonical_tags(_absolute_http_url(share_url))
image_size_tags = _og_image_size_tags(image_url, fallback_image_url)
escaped_title = escape(title, quote=True)
escaped_description = escape(description, quote=True)
escaped_image_url = escape(image_url, quote=True)
escaped_share_url = escape(_absolute_http_url(share_url) or detail_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html>
@ -95,14 +97,12 @@ def build_video_share_html(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<meta name="description" content="{escaped_description}">
<link rel="canonical" href="{escaped_share_url}">
{canonical_tags}
<meta property="og:title" content="{escaped_title}">
<meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}">
<meta property="og:url" content="{escaped_share_url}">
<meta property="og:type" content="video.other">
{image_size_tags} <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
@ -117,7 +117,15 @@ def build_video_share_html(
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
</main>
<script>
window.location.replace(document.getElementById("continue-link").href);
(function () {{
var ua = navigator.userAgent || "";
// 이름만으로 판별하면 인앱 브라우저(: KAKAOTALK)까지 크롤러로 잡혀
// 사용자가 중간 페이지에 멈춘다. 전용 토큰만 쓴다.
if (/bot|crawl|spider|slurp|facebookexternalhit|Facebot|Twitterbot|LinkedInBot|Pinterestbot|Slackbot|TelegramBot|WhatsApp|Discordbot|kakaotalk-scrap|Embedly|redditbot|Applebot/i.test(ua)) {{
return;
}}
window.location.replace(document.getElementById("continue-link").href);
}})();
</script>
</body>
</html>
@ -130,6 +138,114 @@ def _normalise_text(value: str | None, fallback: str) -> str:
return normalised or fallback
_OG_DESCRIPTION_MAX_LEN = 300
def _share_title(title: str | None, store_name: str) -> str:
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
stored = _normalise_text(title, "")
if stored:
return stored
name = _normalise_text(store_name, "ADO2 영상")
return f"{name} | ADO2"
def _share_description(description: str | None) -> str:
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
stored = _normalise_text(description, "")
if stored:
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + ""
return stored
return "ADO2 AI 마케팅 영상"
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return _normalise_frontend_base_url(fallback)
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
def resolve_share_url(
headers: Mapping[str, str],
request_url: str,
configured_api_base_url: str = "",
) -> str:
"""크롤러가 다시 읽어도 같은 OG 페이지가 나오는 공유 URL을 만듭니다.
nginx가 ``/api`` prefix를 떼고 넘기면 ``request.url``에는 prefix가 없어,
그대로 쓰면 프론트 SPA 주소가 된다. 복원할 근거가 없으면 문자열을 돌려
호출부가 canonical/og:url을 생략하도록 한다.
"""
path = urlsplit(request_url).path
configured_base = _absolute_http_url(configured_api_base_url)
if configured_base:
return f"{configured_base.rstrip('/')}{path}"
origin = _forwarded_origin(headers) or _origin_from_url(request_url)
if not origin:
return ""
forwarded_prefix = (headers.get("x-forwarded-prefix") or "").strip().rstrip("/")
if forwarded_prefix:
return f"{origin}{forwarded_prefix}{path}"
if headers.get("x-forwarded-host"):
# 프록시 뒤인데 prefix 를 못 받았다. 잘못된 URL 을 내보내지 않는다.
return ""
return f"{origin}{path}"
def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
"""프록시가 넘긴 외부 호스트 기준 origin을 만듭니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return None
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return f"{forwarded_proto}://{forwarded_host}"
def _canonical_tags(canonical_url: str | None) -> str:
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
잘못된 og:url을 내보내면 크롤러가 주소를 다시 읽어, OG 메타가 없는
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
URL을 쓰도록 태그 자체를 생략한다.
"""
if not canonical_url:
return ""
escaped = escape(canonical_url, quote=True)
return (
f' <link rel="canonical" href="{escaped}">\n'
f' <meta property="og:url" content="{escaped}">\n'
)
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
if image_url != fallback_image_url:
return ""
width, height = _DEFAULT_OG_IMAGE_SIZE
return (
f' <meta property="og:image:width" content="{width}">\n'
f' <meta property="og:image:height" content="{height}">\n'
)
def _normalise_frontend_base_url(value: str) -> str:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
@ -148,20 +264,37 @@ def _resolve_default_image_url(
우선순위:
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
2. 공유 API 호스트 ``/static/images/ado2_image.png``
2. 공유 URL과 같은 API 베이스 ``.../static/images/ado2_image.png``
(``/api/video/share/1`` 이면 ``/api/static/...``)
3. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
"""
configured_absolute_url = _absolute_http_url(configured_url)
if configured_absolute_url:
return configured_absolute_url
share_origin = _origin_from_url(share_url)
if share_origin:
return f"{share_origin}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
share_api_base = _api_base_from_share_url(share_url)
if share_api_base:
return f"{share_api_base}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
def _api_base_from_share_url(share_url: str) -> str | None:
"""공유 URL에서 API 베이스를 만듭니다. ``/api/video/share/1`` → ``https://host/api``."""
absolute_url = _absolute_http_url(share_url)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
idx = (parts.path or "").find("/video/share/")
if idx < 0:
return origin
prefix = parts.path[:idx].rstrip("/")
return f"{origin}{prefix}" if prefix else origin
def _origin_from_url(value: str) -> str | None:
"""URL에서 scheme + host(+port) origin만 추출합니다."""
absolute_url = _absolute_http_url(value)

View File

@ -37,6 +37,14 @@ class ProjectSettings(BaseSettings):
default="https://ado2.o2osolution.ai",
description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)",
)
SHARE_API_BASE_URL: str = Field(
default="",
description=(
"공유 OG 페이지가 외부에 노출되는 API 기준 URL (.env: SHARE_API_BASE_URL). "
"예: https://dev-ssul.castad.net/api. 프록시가 /api prefix 를 떼고 넘기면 "
"request.url 로는 복원할 수 없으므로 이 값이 필요하다"
),
)
SHARE_DEFAULT_IMAGE_URL: str = Field(
default="",
description=(