189 lines
6.1 KiB
Python
189 lines
6.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/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
|
|
|
|
|
|
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,
|
|
share_url=share_url,
|
|
)
|
|
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 마케팅 영상"
|
|
)
|
|
|
|
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,
|
|
*,
|
|
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
|