162 lines
5.1 KiB
Python
162 lines
5.1 KiB
Python
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
|
|
|
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.video.models import Video
|
|
|
|
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
|
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/hero-background.png"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VideoShareData:
|
|
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
|
|
|
video_id: int
|
|
poster_url: str | None
|
|
store_name: str
|
|
region: str
|
|
|
|
|
|
async def get_video_share_data(
|
|
session: AsyncSession,
|
|
video_id: int,
|
|
) -> VideoShareData | None:
|
|
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
|
|
result = await session.execute(
|
|
select(
|
|
Video.id,
|
|
Video.poster_url,
|
|
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,
|
|
)
|
|
|
|
|
|
def build_video_share_html(
|
|
data: VideoShareData,
|
|
*,
|
|
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}/video/{data.video_id}"
|
|
fallback_image_url = _resolve_default_image_url(
|
|
configured_default_image_url,
|
|
frontend_base,
|
|
)
|
|
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} · ADO2 AI 마케팅 영상"
|
|
if region
|
|
else "ADO2 AI 마케팅 영상"
|
|
)
|
|
|
|
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>
|
|
<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_share_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_share_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
|
|
|
|
|
|
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) -> str:
|
|
"""설정된 기본 이미지 또는 프론트엔드의 공개 기본 이미지를 반환합니다."""
|
|
configured_absolute_url = _absolute_http_url(configured_url)
|
|
if configured_absolute_url:
|
|
return configured_absolute_url
|
|
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
|
|
|
|
|
|
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
|