o2o-castad-backend/app/video/services/share_page.py

312 lines
9.8 KiB
Python

"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass
from html import escape
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.video.models import Video
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
@dataclass(frozen=True, slots=True)
class VideoShareData:
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
video_id: int
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class SsulShareData:
"""공유 페이지에 필요한 썰박스 콘텐츠 정보."""
content_id: int
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
async def get_video_share_data(
session: AsyncSession,
video_id: int,
) -> VideoShareData | None:
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
result = await session.execute(
select(
Video.id,
Video.poster_url,
Video.title,
Video.description,
Project.store_name,
Project.region,
)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
)
)
row = result.one_or_none()
if row is None:
return None
return VideoShareData(
video_id=row.id,
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region,
title=row.title,
description=row.description,
)
async def get_ssul_share_data(
session: AsyncSession,
content_id: int,
) -> SsulShareData | None:
"""공유 가능한 완료 썰박스 콘텐츠를 조회합니다."""
result = await session.execute(
select(
SsulContent.id,
SsulContent.poster_url,
SsulContent.title,
SsulContent.description,
SsulContent.store_name,
SsulContent.region,
).where(
SsulContent.id == content_id,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
SsulContent.video_url.is_not(None),
SsulContent.video_url != "",
)
)
row = result.one_or_none()
if row is None:
return None
return SsulShareData(
content_id=row.id,
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region or "",
title=row.title,
description=row.description,
)
def build_video_share_html(
data: VideoShareData,
*,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str = "",
) -> str:
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
return _build_share_html(
detail_path=f"/video/{data.video_id}",
poster_url=data.poster_url,
title=_share_title(data.title, data.store_name, fallback_store="ADO2 영상"),
description=_share_description(data.description),
share_url=share_url,
frontend_base_url=frontend_base_url,
configured_default_image_url=configured_default_image_url,
)
def build_ssul_share_html(
data: SsulShareData,
*,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str = "",
) -> str:
"""썰박스 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
return _build_share_html(
detail_path=f"/ssul/{data.content_id}",
poster_url=data.poster_url,
title=_share_title(data.title, data.store_name, fallback_store="ADO2 썰"),
description=_share_description(data.description),
share_url=share_url,
frontend_base_url=frontend_base_url,
configured_default_image_url=configured_default_image_url,
)
def _build_share_html(
*,
detail_path: str,
poster_url: str | None,
title: str,
description: str,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str,
) -> str:
"""크롤러용 OG 메타와, 사람용 프론트 상세 이동 링크를 포함한 HTML을 만듭니다."""
frontend_base = _normalise_frontend_base_url(frontend_base_url)
detail_url = f"{frontend_base}{detail_path}"
fallback_image_url = _resolve_default_image_url(
configured_default_image_url,
frontend_base,
share_url=share_url,
)
image_url = _absolute_http_url(poster_url) or fallback_image_url
canonical_url = _absolute_http_url(share_url) or detail_url
escaped_title = escape(title, quote=True)
escaped_description = escape(description, quote=True)
escaped_image_url = escape(image_url, quote=True)
escaped_canonical_url = escape(canonical_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<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_canonical_url}">
<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_canonical_url}">
<meta property="og:type" content="video.other">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
<meta name="twitter:description" content="{escaped_description}">
<meta name="twitter:image" content="{escaped_image_url}">
</head>
<body>
<main>
<h1>{escaped_title}</h1>
<p>{escaped_description}</p>
<a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
</main>
<script>
window.location.replace(document.getElementById("continue-link").href);
</script>
</body>
</html>
"""
def _normalise_text(value: str | None, fallback: str) -> str:
"""메타데이터용 텍스트에서 불필요한 공백을 제거합니다."""
normalised = " ".join((value or "").split())
return normalised or fallback
_OG_DESCRIPTION_MAX_LEN = 300
def _share_title(title: str | None, store_name: str, *, fallback_store: str) -> str:
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
stored = _normalise_text(title, "")
if stored:
return stored
name = _normalise_text(store_name, fallback_store)
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 _normalise_frontend_base_url(value: str) -> str:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
parts = urlsplit(absolute_url)
path = parts.path.rstrip("/")
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def _resolve_default_image_url(
configured_url: str,
frontend_base: str,
*,
share_url: str = "",
) -> str:
"""포스터가 없을 때 사용할 기본 OG 이미지 URL을 반환합니다.
우선순위:
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
2. 공유 API 호스트 ``/static/images/ado2_image.png``
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}"
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
def _origin_from_url(value: str) -> str | None:
"""URL에서 scheme + host(+port) origin만 추출합니다."""
absolute_url = _absolute_http_url(value)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
def _absolute_http_url(value: str | None) -> str | None:
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
candidate = (value or "").strip()
if not candidate:
return None
try:
parts = urlsplit(candidate)
except ValueError:
return None
if parts.scheme.lower() not in {"http", "https"} or not parts.hostname:
return None
return candidate