From 1b982b6ae1b1a008d0739f08c9226d2322763dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 20 Aug 2026 16:35:19 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=ED=95=98=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/video/api/routers/v1/video.py | 2 ++ app/video/schemas/video_schema.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 864fb8a..2939e71 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -1209,6 +1209,8 @@ async def get_video_detail( poster_url=video.poster_url, store_name=project.store_name, region=project.region or _extract_region_from_address(project.detail_region_info), + title=video.title, + description=video.description, created_at=video.created_at, like_count=like_count, is_liked_by_me=is_liked_by_me, diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index 1273912..2da6aba 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -200,6 +200,8 @@ class VideoDetailResponse(BaseModel): poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") store_name: Optional[str] = Field(None, description="업체명") region: Optional[str] = Field(None, description="지역명") + title: Optional[str] = Field(None, description="SNS 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)") + description: Optional[str] = Field(None, description="SNS 업로드 설명") created_at: datetime = Field(..., description="생성 일시") like_count: int = Field(..., description="좋아요 수") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") From 9d40313fbbee29c76891488dc4f4678fde1beb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 21 Aug 2026 08:48:08 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=B6=81?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=ED=95=98=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + app/video/api/routers/v1/video.py | 18 +++- app/video/services/share_page.py | 169 ++++++++++++++++++++++++++---- config.py | 8 ++ 4 files changed, 175 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b79bbb8..119d80c 100644 --- a/README.md +++ b/README.md @@ -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: 운영) diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 2939e71..795f86f 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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( diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py index 2459b19..d98568c 100644 --- a/app/video/services/share_page.py +++ b/app/video/services/share_page.py @@ -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""" @@ -95,14 +97,12 @@ def build_video_share_html( {escaped_title} - - +{canonical_tags} - - +{image_size_tags} @@ -117,7 +117,15 @@ def build_video_share_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' \n' + f' \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' \n' + f' \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) diff --git a/config.py b/config.py index c80a784..278b5c9 100644 --- a/config.py +++ b/config.py @@ -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=( From 61bdc27cd32e937b5566a6f295a4d8de2bf6df40 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 10:44:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(video):=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20official=5Fsite=5Furl=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /video/{id}와 GET /video/all 응답에 업체 공식 페이지 URL을 추가한다. Project.marketing_intelligence → MarketingIntel.place_id("nv{place_id}")를 경유해 네이버 플레이스 URL로 복원하며, 크롤링 없이 직접 입력된 업체는 null로 내려간다. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/video/api/routers/v1/video.py | 53 +++++++++++++++++++++++++++++++ app/video/schemas/video_schema.py | 8 +++++ 2 files changed, 61 insertions(+) diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 795f86f..9129552 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -74,6 +74,51 @@ logger = get_logger("video") router = APIRouter(prefix="/video", tags=["Video"]) +def _place_id_to_site_url(place_id: str | None) -> str | None: + """MarketingIntel.place_id("nv{네이버 place ID}")를 네이버 플레이스 URL로 변환한다. + + 크롤링 없이 직접 입력된 업체는 place_id가 없으므로 None을 반환한다. + """ + if place_id and place_id.startswith("nv") and place_id[2:].isdigit(): + return f"https://map.naver.com/p/entry/place/{place_id[2:]}" + return None + + +async def _get_official_site_urls( + session: AsyncSession, projects: list[Project] +) -> dict[int, str | None]: + """프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다. + + Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해 + place_id를 찾고, 이를 네이버 플레이스 URL로 변환한다. + """ + m_id_by_project: dict[int, int] = {} + for p in projects: + try: + if p.marketing_intelligence is not None: + m_id_by_project[p.id] = int(p.marketing_intelligence) + except (TypeError, ValueError): + continue + + url_by_project: dict[int, str | None] = {p.id: None for p in projects} + if not m_id_by_project: + return url_by_project + + rows = ( + await session.execute( + select(MarketingIntel.id, MarketingIntel.place_id).where( + MarketingIntel.id.in_(set(m_id_by_project.values())) + ) + ) + ).all() + place_id_by_m_id = {m_id: place_id for m_id, place_id in rows} + + for project_id, m_id in m_id_by_project.items(): + url_by_project[project_id] = _place_id_to_site_url( + place_id_by_m_id.get(m_id) + ) + return url_by_project + @router.get( "/generate/{task_id}", @@ -989,6 +1034,10 @@ async def get_all_videos( liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()} + official_site_url_map = await _get_official_site_urls( + session, [p for _, p, _ in rows] + ) + items = [ VideoThumbnailItem( video_id=v.id, @@ -999,6 +1048,7 @@ async def get_all_videos( like_count=like_count_map.get(v.id) or 0, is_liked_by_me=liked_map.get(v.id, False), comment_count=comment_count or 0, + official_site_url=official_site_url_map.get(p.id), ) for v, p, comment_count in rows ] @@ -1214,6 +1264,8 @@ async def get_video_detail( liked = False is_liked_by_me = liked + official_site_url_map = await _get_official_site_urls(session, [project]) + logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}") return VideoDetailResponse( video_id=video.id, @@ -1226,6 +1278,7 @@ async def get_video_detail( created_at=video.created_at, like_count=like_count, is_liked_by_me=is_liked_by_me, + official_site_url=official_site_url_map.get(project.id), ) except HTTPException: diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index 2da6aba..d921607 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -186,6 +186,10 @@ class VideoThumbnailItem(BaseModel): like_count: int = Field(..., description="좋아요 수") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") + official_site_url: Optional[str] = Field( + None, + description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)", + ) class VideoDetailResponse(BaseModel): @@ -205,6 +209,10 @@ class VideoDetailResponse(BaseModel): created_at: datetime = Field(..., description="생성 일시") like_count: int = Field(..., description="좋아요 수") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") + official_site_url: Optional[str] = Field( + None, + description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)", + ) class LikeToggleResponse(BaseModel): From 1ef8e82638015999d977c23880a28f065448dd44 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 10:49:46 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(crawling):=20=EC=97=85=EC=B2=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=9D=20=EB=A7=81=ED=81=AC=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=C2=B7=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20official=5Fsite=5Furl=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네이버 placeDetail GraphQL base에 homepages 필드 추가, 대표(repr) 링크 우선으로 살아있는 URL을 추출한다 (실서비스 응답 구조 확인 완료) - marketing 테이블에 official_site_url 컬럼 추가: 크롤링 시 플레이스 홈페이지 항목을 저장하고, 없으면 유저가 입력한 크롤링 소스 URL을 저장 - GET /video/{id}, /video/all의 official_site_url은 저장값 우선, 컬럼 도입 전 기존 행은 place_id 기반 플레이스 URL로 폴백 - 기존 테이블에는 수동 마이그레이션 필요: ALTER TABLE marketing ADD COLUMN official_site_url VARCHAR(2048) NULL COMMENT '업체 공식 링크' AFTER place_id; Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/home/api/routers/v1/home.py | 8 ++++++++ app/home/models.py | 7 +++++++ app/utils/nvMapScraper.py | 20 ++++++++++++++++++++ app/video/api/routers/v1/video.py | 18 ++++++++++-------- app/video/schemas/video_schema.py | 4 ++-- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 42d9515..b1575f1 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -417,8 +417,16 @@ async def _crawling_logic(url: str, session: AsyncSession): ) # Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장) + # 공식 링크: 플레이스 홈페이지 항목 우선, 없으면 유저가 입력한 크롤링 소스 URL + # (컬럼 길이를 넘는 긴 검색 URL은 place_id 기반 표준 플레이스 URL로 대체) + official_site_url = scraper.official_site_url or url + if len(official_site_url) > 2048: + official_site_url = ( + f"https://map.naver.com/p/entry/place/{scraper.place_id[2:]}" + ) marketing_intel = MarketingIntel( place_id=scraper.place_id, + official_site_url=official_site_url, intel_result=marketing_analysis.model_dump(), ) session.add(marketing_intel) diff --git a/app/home/models.py b/app/home/models.py index 77025ec..f99b873 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -274,6 +274,7 @@ class MarketingIntel(Base): Attributes: id: 고유 식별자 (자동 증가) place_id : 데이터 소스별 식별자 + official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL) intel_result : 마케팅 분석 결과물 json created_at: 생성 일시 (자동 설정) """ @@ -302,6 +303,12 @@ class MarketingIntel(Base): comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)", ) + official_site_url: Mapped[Optional[str]] = mapped_column( + String(2048), + nullable=True, + comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)", + ) + intel_result : Mapped[dict[str, Any]] = mapped_column( JSON, nullable=False, diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index a029e0d..a46bdc6 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -56,6 +56,10 @@ query getAccommodation($id: String!, $deviceType: String) { microReviews conveniences visitorReviewsTotal + homepages { + repr { url landingUrl isDeadUrl type } + etc { url landingUrl isDeadUrl type } + } } menus { name @@ -112,6 +116,7 @@ query getVisitorReviewStats($id: String!) { self.facility_info: str | None = None self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count) self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend) + self.official_site_url: str | None = None # 업체 공식 링크 (base.homepages 대표 URL) def _get_request_headers(self) -> dict: headers = self.DEFAULT_HEADERS.copy() @@ -297,9 +302,24 @@ query getVisitorReviewStats($id: String!) { self.facility_info = fac_data self.voted_keyword_stats = stats_data self.menu_info = business.get("menus") or None + self.official_site_url = self._extract_official_site_url(self.base_info) return + @staticmethod + def _extract_official_site_url(base_info: dict | None) -> str | None: + """base.homepages에서 살아있는 링크 하나를 고른다 (대표 repr 우선, 그 다음 etc 순서). + + 네이버 플레이스의 홈페이지 항목은 자체 홈페이지 외에 인스타그램/블로그 + 등일 수도 있다 — 업체가 대표로 등록한 링크를 그대로 신뢰한다. + """ + homepages = (base_info or {}).get("homepages") or {} + candidates = [homepages.get("repr"), *(homepages.get("etc") or [])] + for item in candidates: + if item and item.get("url") and not item.get("isDeadUrl"): + return item["url"] + return None + async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]: """직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다. diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 9129552..041674c 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -90,7 +90,8 @@ async def _get_official_site_urls( """프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다. Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해 - place_id를 찾고, 이를 네이버 플레이스 URL로 변환한다. + 저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은 + place_id 기반 네이버 플레이스 URL로 폴백한다. """ m_id_by_project: dict[int, int] = {} for p in projects: @@ -106,17 +107,18 @@ async def _get_official_site_urls( rows = ( await session.execute( - select(MarketingIntel.id, MarketingIntel.place_id).where( - MarketingIntel.id.in_(set(m_id_by_project.values())) - ) + select( + MarketingIntel.id, + MarketingIntel.place_id, + MarketingIntel.official_site_url, + ).where(MarketingIntel.id.in_(set(m_id_by_project.values()))) ) ).all() - place_id_by_m_id = {m_id: place_id for m_id, place_id in rows} + intel_by_m_id = {m_id: (place_id, site_url) for m_id, place_id, site_url in rows} for project_id, m_id in m_id_by_project.items(): - url_by_project[project_id] = _place_id_to_site_url( - place_id_by_m_id.get(m_id) - ) + place_id, site_url = intel_by_m_id.get(m_id, (None, None)) + url_by_project[project_id] = site_url or _place_id_to_site_url(place_id) return url_by_project diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index d921607..a53c9b2 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -188,7 +188,7 @@ class VideoThumbnailItem(BaseModel): comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") official_site_url: Optional[str] = Field( None, - description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)", + description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)", ) @@ -211,7 +211,7 @@ class VideoDetailResponse(BaseModel): is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") official_site_url: Optional[str] = Field( None, - description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)", + description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)", ) From 98c6515192dd7e6f8c43bb4781a58af4b11b4294 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 10:57:42 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(marketing):=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=BD=EB=A1=9C=EC=97=90=20official=5Fs?= =?UTF-8?q?ite=5Furl=20=EC=9E=85=EB=A0=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /marketing 요청에 official_site_url(선택, 기본 null)을 추가한다. http/https 스킴만 허용하고 2048자 초과는 422로 거부하며, 값이 있으면 marketing.official_site_url에 저장돼 기존 영상 조회 응답에 그대로 노출된다. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/home/api/routers/v1/home.py | 2 ++ app/home/schemas/home_schema.py | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index b1575f1..87dffec 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -494,6 +494,7 @@ async def _crawling_logic(url: str, session: AsyncSession): - **customer_name**: 업체명 / 브랜드명 (필수) - **address**: 도로명 또는 지번 주소 (필수) - **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류 +- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출 ## 반환 정보 - **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) @@ -537,6 +538,7 @@ async def manual_marketing( # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) marketing_intel = MarketingIntel( place_id=None, + official_site_url=request_body.official_site_url, intel_result=marketing_analysis.model_dump(), ) session.add(marketing_intel) diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index a6635e5..05189e5 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -1,6 +1,6 @@ from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.utils.prompts.schemas import MarketingPromptOutput class CrawlingRequest(BaseModel): @@ -261,6 +261,7 @@ class ManualMarketingRequest(BaseModel): "store_name": "스테이 머뭄", "address": "전북특별자치도 군산시 절골길 18", "category": "펜션", + "official_site_url": "https://www.staymeomoom.com", } } ) @@ -268,6 +269,23 @@ class ManualMarketingRequest(BaseModel): store_name: str = Field(..., description="업체명 / 브랜드명") address: str = Field(..., description="도로명 또는 지번 주소") category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류") + official_site_url: Optional[str] = Field( + default=None, + max_length=2048, + description="업체 공식 홈페이지 링크 (선택, http/https만 허용). 영상 응답의 official_site_url로 노출됨", + ) + + @field_validator("official_site_url") + @classmethod + def _validate_official_site_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + if not v: + return None + if not v.startswith(("http://", "https://")): + raise ValueError("official_site_url은 http:// 또는 https://로 시작해야 합니다.") + return v class ErrorResponse(BaseModel): From 87cb3eb3cbf3bf053c4dd59a24a3f2f4247a5c8d Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 11:12:20 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(db):=20create=5Fdb=5Ftables=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20Comment=C2=B7VideoReaction=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 DB 셋업 시 두 테이블이 생성되지 않아 /video/all 등 댓글·좋아요 조회가 즉시 실패하던 누락을 보완한다. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/database/session.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/database/session.py b/app/database/session.py index e405fef..1e45eaa 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -80,7 +80,8 @@ async def create_db_tables(): from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401 from app.lyric.models import Lyric # noqa: F401 from app.song.models import Song, SongTimestamp # noqa: F401 - from app.video.models import Video # noqa: F401 + from app.video.models import Video, VideoReaction # noqa: F401 + from app.comment.models import Comment # noqa: F401 from app.sns.models import SNSUploadTask # noqa: F401 from app.social.models import SocialUpload # noqa: F401 from app.dashboard.models import Dashboard # noqa: F401 @@ -98,6 +99,8 @@ async def create_db_tables(): Song.__table__, SongTimestamp.__table__, Video.__table__, + VideoReaction.__table__, + Comment.__table__, SNSUploadTask.__table__, SocialUpload.__table__, MarketingIntel.__table__, From 6197fcdc91fe32c39fed4401d88cf11f2e5fea9e Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 21 Aug 2026 11:30:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(crawling):=20homepages=EB=A5=BC=20Graph?= =?UTF-8?q?QL=20=EB=8C=80=EC=8B=A0=20=5F=5FAPOLLO=5FSTATE=5F=5F=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeDetail(base)의 homepages 필드는 스키마에 없어 전체 크롤링이 400으로 실패했다(컨테이너 검증 완료). 쿼리에서 제거하고, WTM 우회용 브라우저가 이미 로드하는 place 페이지의 __APOLLO_STATE__ JSON을 캡처해 대표(repr) 링크를 추출한다. 브라우저 캡처 실패 시 HTML 직접 파싱으로 보충한다(직접 요청은 429로 막히는 환경이 있어 best-effort). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS --- app/utils/nvMapPwScraper.py | 28 +++++++++-- app/utils/nvMapScraper.py | 95 +++++++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 35 deletions(-) diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py index 0fc13f3..9dd4a26 100644 --- a/app/utils/nvMapPwScraper.py +++ b/app/utils/nvMapPwScraper.py @@ -131,7 +131,8 @@ if (originalQuery) { cls, place_id: str, payloads: list[dict], - ) -> list[dict | None] | None: + capture_apollo: bool = False, + ) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]: """실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다. 네이버 pcmap GraphQL은 두 헤더를 검사한다: @@ -144,12 +145,19 @@ if (originalQuery) { Args: place_id: 네이버 place ID payloads: GraphQL POST 본문 목록 + capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__ + JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환. + (GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용) Returns: payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None. + capture_apollo=True면 (위 결과, apollo_json 또는 None) 튜플. """ + def _ret(results, apollo=None): + return (results, apollo) if capture_apollo else results + if not cls.is_ready: logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다") - return None + return _ret(None) page = await cls._new_stealth_page() captured: dict = {} @@ -185,7 +193,17 @@ if (originalQuery) { if not captured.get("tok"): logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패") - return None + return _ret(None) + + apollo_json: str | None = None + if capture_apollo: + try: + apollo_json = await page.evaluate( + "() => { try { return JSON.stringify(window.__APOLLO_STATE__ || null); }" + " catch (e) { return null; } }" + ) + except Exception as e: + logger.warning(f"[NvMapPwScraper] __APOLLO_STATE__ 캡처 실패: {e}") headers = { "Content-Type": "application/json", @@ -220,11 +238,11 @@ if (originalQuery) { results.append(None) else: results.append(r) - return results + return _ret(results, apollo_json) except Exception as e: logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}") - return None + return _ret(None) finally: await page.close() diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index a46bdc6..bfc172c 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -56,10 +56,6 @@ query getAccommodation($id: String!, $deviceType: String) { microReviews conveniences visitorReviewsTotal - homepages { - repr { url landingUrl isDeadUrl type } - etc { url landingUrl isDeadUrl type } - } } menus { name @@ -261,9 +257,11 @@ query getVisitorReviewStats($id: String!) { # self.scrap_type = "GraphQL-Browser" # ── 실제 브라우저로 WTM 캡차 우회 ── - data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id) + data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = await self._scrap_via_browser(place_id) # 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None) - fac_data = await self._get_facility_string(place_id) + # 홈페이지 링크는 브라우저 캡처가 실패한 경우에만 HTML 경로로 보충한다. + fac_data, html_homepage = await self._get_facility_and_homepage(place_id) + homepage_url = homepage_url or html_homepage self.scrap_type = "GraphQL-Browser" self.rawdata = data @@ -302,29 +300,43 @@ query getVisitorReviewStats($id: String!) { self.facility_info = fac_data self.voted_keyword_stats = stats_data self.menu_info = business.get("menus") or None - self.official_site_url = self._extract_official_site_url(self.base_info) + self.official_site_url = homepage_url return @staticmethod - def _extract_official_site_url(base_info: dict | None) -> str | None: - """base.homepages에서 살아있는 링크 하나를 고른다 (대표 repr 우선, 그 다음 etc 순서). + def _extract_homepage_from_html(html: str) -> str | None: + """플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다. - 네이버 플레이스의 홈페이지 항목은 자체 홈페이지 외에 인스타그램/블로그 - 등일 수도 있다 — 업체가 대표로 등록한 링크를 그대로 신뢰한다. + GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400), + SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다. + 대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은 + 자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로 + 등록한 링크를 그대로 신뢰한다. """ - homepages = (base_info or {}).get("homepages") or {} - candidates = [homepages.get("repr"), *(homepages.get("etc") or [])] - for item in candidates: - if item and item.get("url") and not item.get("isDeadUrl"): - return item["url"] - return None + decoder = json.JSONDecoder() + search_from = 0 + while True: + idx = html.find('"homepages":', search_from) + if idx == -1: + return None + search_from = idx + 1 + try: + homepages, _ = decoder.raw_decode(html, idx + len('"homepages":')) + except ValueError: + continue + if not isinstance(homepages, dict): + continue + candidates = [homepages.get("repr"), *(homepages.get("etc") or [])] + for item in candidates: + if isinstance(item, dict) and item.get("url") and not item.get("isDeadUrl"): + return item["url"] - async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]: + async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict], str | None]: """직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다. Returns: - (overview_data, review_stats_details, extra_photo_urls, biz_photo_urls) + (overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url) Raises: GraphQLException: 브라우저 폴백마저 실패한 경우 @@ -402,10 +414,14 @@ query getVisitorReviewStats($id: String!) { payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads] MAX_RETRY = 3 results = None + apollo_json: str | None = None last_error: Exception | None = None for attempt in range(1, MAX_RETRY + 1): try: - results = await NvMapPwScraper.fetch_graphql(place_id, payloads) + results, captured_apollo = await NvMapPwScraper.fetch_graphql( + place_id, payloads, capture_apollo=True + ) + apollo_json = apollo_json or captured_apollo except Exception as e: last_error = e logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}") @@ -459,8 +475,17 @@ query getVisitorReviewStats($id: String!) { f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}" ) - logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}") - return data, stats_data, extra_photo_urls, biz_urls + # 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400), + # 브라우저가 로드한 place 페이지의 __APOLLO_STATE__ JSON에서 추출한다. + homepage_url = ( + self._extract_homepage_from_html(apollo_json) if apollo_json else None + ) + + logger.info( + f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}, " + f"homepage: {homepage_url or '없음'}" + ) + return data, stats_data, extra_photo_urls, biz_urls, homepage_url async def _call_get_accommodation(self, place_id: str) -> dict: """GraphQL API를 호출하여 숙소 정보를 가져옵니다. @@ -541,29 +566,39 @@ query getVisitorReviewStats($id: String!) { logger.warning(f"[NvMapScraper] Failed to get review stats: {e}") return None - async def _get_facility_string(self, place_id: str) -> str | None: - """장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다. + async def _get_facility_and_homepage(self, place_id: str) -> tuple[str | None, str | None]: + """장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다. Args: place_id: 네이버 지도 장소 ID Returns: - 편의시설 정보 문자열 또는 None + (편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None) """ + facility: str | None = None + homepage: str | None = None place_types = ["place", "accommodation", "restaurant"] try: async with aiohttp.ClientSession() as session: for place_type in place_types: url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home" async with session.get(url, headers=self._get_request_headers()) as response: - soup = bs4.BeautifulSoup(await response.read(), "html.parser") + raw = await response.read() + if homepage is None: + homepage = self._extract_homepage_from_html( + raw.decode("utf-8", errors="ignore") + ) + if facility is None: + soup = bs4.BeautifulSoup(raw, "html.parser") c_elem = soup.find("span", "place_blind", string="편의") if c_elem: - return c_elem.parent.parent.find("div").string - return None + facility = c_elem.parent.parent.find("div").string + if facility is not None and homepage is not None: + break + return facility, homepage except Exception as e: - logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") - return None + logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}") + return facility, homepage # if __name__ == "__main__": From ac7e4bde9f33af2b495a5e7d0498ecb26141e199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Mon, 24 Aug 2026 10:16:40 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(video):=20=EC=9E=AC=EC=83=9D=20URL?= =?UTF-8?q?=EC=97=90=20SAS=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=B6=99?= =?UTF-8?q?=EC=97=AC=20=EC=98=81=EC=83=81=20seek=20=EC=95=88=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/archive/api/routers/v1/archive.py | 3 ++- app/utils/upload_blob_as_request.py | 17 +++++++++++++++++ app/video/api/routers/v1/video.py | 7 ++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index bd75c9f..6196d69 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -16,6 +16,7 @@ from app.user.dependencies.auth import get_current_user from app.user.models import User from app.utils.logger import get_logger from app.utils.pagination import PaginatedResponse +from app.utils.upload_blob_as_request import to_playback_url from app.comment.models import Comment from app.database.like_cache import ( bulk_is_user_liked, @@ -178,7 +179,7 @@ async def get_videos( store_name=project.store_name, region=project.region, task_id=video.task_id, - result_movie_url=video.result_movie_url, + result_movie_url=to_playback_url(video.result_movie_url), poster_url=video.poster_url, title=video.title, description=video.description, diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index f3f9857..6a03e75 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -90,6 +90,23 @@ async def close_shared_blob_client() -> None: logger.info("[AzureBlobUploader] Shared HTTP client closed") +def to_playback_url(blob_url: str | None) -> str | None: + """DB에 저장된 SAS 미포함 공개 URL에 읽기용 SAS 토큰을 붙여 반환합니다. + + Azure Blob 익명(공개) 접근은 x-ms-version 헤더가 없어 Range 요청을 지원하지 + 않는 구버전 API로 처리되어 브라우저에서 영상/오디오 seek이 동작하지 않는다. + SAS 토큰(sv= 포함)을 붙이면 최신 API 버전으로 처리되어 Range/Accept-Ranges가 + 정상 동작한다. DB에는 SAS 없는 URL을 그대로 저장하고, 응답 시점에만 붙인다. + """ + if not blob_url: + return blob_url + sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN.strip("?'\"") + if not sas_token: + return blob_url + separator = "&" if "?" in blob_url else "?" + return f"{blob_url}{separator}{sas_token}" + + class AzureBlobUploader: """Azure Blob Storage 업로드 클래스 diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 041674c..78bec24 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -30,6 +30,7 @@ from app.utils.pagination import PaginatedResponse from app.home.models import Image, Project, MarketingIntel from app.home.api.routers.v1.home import _extract_region_from_address from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES +from app.utils.upload_blob_as_request import to_playback_url from app.lyric.models import Lyric from app.song.models import Song, SongTimestamp from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP @@ -860,7 +861,7 @@ async def download_video( store_name=project.store_name if project else None, region=project.region or _extract_region_from_address(project.detail_region_info) if project else None, task_id=task_id, - result_movie_url=video.result_movie_url, + result_movie_url=to_playback_url(video.result_movie_url), created_at=video.created_at, ) @@ -1044,7 +1045,7 @@ async def get_all_videos( VideoThumbnailItem( video_id=v.id, store_name=p.store_name, - result_movie_url=v.result_movie_url, + result_movie_url=to_playback_url(v.result_movie_url), poster_url=v.poster_url, created_at=v.created_at, like_count=like_count_map.get(v.id) or 0, @@ -1271,7 +1272,7 @@ async def get_video_detail( logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}") return VideoDetailResponse( video_id=video.id, - result_movie_url=video.result_movie_url, + result_movie_url=to_playback_url(video.result_movie_url), poster_url=video.poster_url, store_name=project.store_name, region=project.region or _extract_region_from_address(project.detail_region_info),