Merge branch 'main' into feature-ssulbox: SAS 재생 URL·공식 링크 응답 반영

This commit is contained in:
김성경 2026-08-24 11:20:46 +09:00
commit 934410a83b
10 changed files with 216 additions and 26 deletions

View File

@ -16,6 +16,7 @@ from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.utils.upload_blob_as_request import to_playback_url
from app.video.models import Video from app.video.models import Video
from app.video.schemas.video_schema import VideoListItem from app.video.schemas.video_schema import VideoListItem
from app.video.services import unified_list from app.video.services import unified_list
@ -93,7 +94,7 @@ async def get_videos(
# 썰박스는 task_id 개념이 없어 빈 문자열이다. # 썰박스는 task_id 개념이 없어 빈 문자열이다.
# 프론트는 반드시 (type, video_id) 쌍으로 식별할 것. # 프론트는 반드시 (type, video_id) 쌍으로 식별할 것.
task_id=it.task_id, task_id=it.task_id,
result_movie_url=it.movie_url, result_movie_url=to_playback_url(it.movie_url),
poster_url=it.poster_url, poster_url=it.poster_url,
title=it.title, title=it.title,
description=it.description, description=it.description,

View File

@ -80,7 +80,8 @@ async def create_db_tables():
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401 from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
from app.lyric.models import Lyric # noqa: F401 from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # 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.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401 from app.social.models import SocialUpload # noqa: F401
from app.dashboard.models import Dashboard # noqa: F401 from app.dashboard.models import Dashboard # noqa: F401
@ -101,6 +102,8 @@ async def create_db_tables():
Song.__table__, Song.__table__,
SongTimestamp.__table__, SongTimestamp.__table__,
Video.__table__, Video.__table__,
VideoReaction.__table__,
Comment.__table__,
SNSUploadTask.__table__, SNSUploadTask.__table__,
SocialUpload.__table__, SocialUpload.__table__,
MarketingIntel.__table__, MarketingIntel.__table__,

View File

@ -417,8 +417,16 @@ async def _crawling_logic(url: str, session: AsyncSession):
) )
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장) # 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( marketing_intel = MarketingIntel(
place_id=scraper.place_id, place_id=scraper.place_id,
official_site_url=official_site_url,
intel_result=marketing_analysis.model_dump(), intel_result=marketing_analysis.model_dump(),
) )
session.add(marketing_intel) session.add(marketing_intel)
@ -486,6 +494,7 @@ async def _crawling_logic(url: str, session: AsyncSession):
- **customer_name**: 업체명 / 브랜드명 (필수) - **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수) - **address**: 도로명 또는 지번 주소 (필수)
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류 - **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출
## 반환 정보 ## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) - **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
@ -529,6 +538,7 @@ async def manual_marketing(
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
marketing_intel = MarketingIntel( marketing_intel = MarketingIntel(
place_id=None, place_id=None,
official_site_url=request_body.official_site_url,
intel_result=marketing_analysis.model_dump(), intel_result=marketing_analysis.model_dump(),
) )
session.add(marketing_intel) session.add(marketing_intel)

View File

@ -274,6 +274,7 @@ class MarketingIntel(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자 place_id : 데이터 소스별 식별자
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
intel_result : 마케팅 분석 결과물 json intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
""" """
@ -302,6 +303,12 @@ class MarketingIntel(Base):
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)", 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( intel_result : Mapped[dict[str, Any]] = mapped_column(
JSON, JSON,
nullable=False, nullable=False,

View File

@ -1,6 +1,6 @@
from typing import Literal, Optional 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 from app.utils.prompts.schemas import MarketingPromptOutput
class CrawlingRequest(BaseModel): class CrawlingRequest(BaseModel):
@ -261,6 +261,7 @@ class ManualMarketingRequest(BaseModel):
"store_name": "스테이 머뭄", "store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18", "address": "전북특별자치도 군산시 절골길 18",
"category": "펜션", "category": "펜션",
"official_site_url": "https://www.staymeomoom.com",
} }
} }
) )
@ -268,6 +269,23 @@ class ManualMarketingRequest(BaseModel):
store_name: str = Field(..., description="업체명 / 브랜드명") store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소") address: str = Field(..., description="도로명 또는 지번 주소")
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류") 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): class ErrorResponse(BaseModel):

View File

@ -131,7 +131,8 @@ if (originalQuery) {
cls, cls,
place_id: str, place_id: str,
payloads: list[dict], payloads: list[dict],
) -> list[dict | None] | None: capture_apollo: bool = False,
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다. """실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
네이버 pcmap GraphQL은 두 헤더를 검사한다: 네이버 pcmap GraphQL은 두 헤더를 검사한다:
@ -144,12 +145,19 @@ if (originalQuery) {
Args: Args:
place_id: 네이버 place ID place_id: 네이버 place ID
payloads: GraphQL POST 본문 목록 payloads: GraphQL POST 본문 목록
capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__
JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환.
(GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용)
Returns: Returns:
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None. 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: if not cls.is_ready:
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다") logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
return None return _ret(None)
page = await cls._new_stealth_page() page = await cls._new_stealth_page()
captured: dict = {} captured: dict = {}
@ -185,7 +193,17 @@ if (originalQuery) {
if not captured.get("tok"): if not captured.get("tok"):
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패") 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 = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -220,11 +238,11 @@ if (originalQuery) {
results.append(None) results.append(None)
else: else:
results.append(r) results.append(r)
return results return _ret(results, apollo_json)
except Exception as e: except Exception as e:
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}") logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
return None return _ret(None)
finally: finally:
await page.close() await page.close()

View File

@ -112,6 +112,7 @@ query getVisitorReviewStats($id: String!) {
self.facility_info: str | None = None self.facility_info: str | None = None
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count) self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend) 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: def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy() headers = self.DEFAULT_HEADERS.copy()
@ -256,9 +257,11 @@ query getVisitorReviewStats($id: String!) {
# self.scrap_type = "GraphQL-Browser" # self.scrap_type = "GraphQL-Browser"
# ── 실제 브라우저로 WTM 캡차 우회 ── # ── 실제 브라우저로 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) # 편의시설은 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.scrap_type = "GraphQL-Browser"
self.rawdata = data self.rawdata = data
@ -297,14 +300,43 @@ query getVisitorReviewStats($id: String!) {
self.facility_info = fac_data self.facility_info = fac_data
self.voted_keyword_stats = stats_data self.voted_keyword_stats = stats_data
self.menu_info = business.get("menus") or None self.menu_info = business.get("menus") or None
self.official_site_url = homepage_url
return return
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]: @staticmethod
def _extract_homepage_from_html(html: str) -> str | None:
"""플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다.
GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400),
SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다.
대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은
자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로
등록한 링크를 그대로 신뢰한다.
"""
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], str | None]:
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다. """직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
Returns: 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: Raises:
GraphQLException: 브라우저 폴백마저 실패한 경우 GraphQLException: 브라우저 폴백마저 실패한 경우
@ -382,10 +414,14 @@ query getVisitorReviewStats($id: String!) {
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads] payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
MAX_RETRY = 3 MAX_RETRY = 3
results = None results = None
apollo_json: str | None = None
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, MAX_RETRY + 1): for attempt in range(1, MAX_RETRY + 1):
try: 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: except Exception as e:
last_error = e last_error = e
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}") logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
@ -439,8 +475,17 @@ query getVisitorReviewStats($id: String!) {
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}" f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
) )
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}") # 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400),
return data, stats_data, extra_photo_urls, biz_urls # 브라우저가 로드한 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: async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다. """GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -521,29 +566,39 @@ query getVisitorReviewStats($id: String!) {
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}") logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
return None 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: Args:
place_id: 네이버 지도 장소 ID place_id: 네이버 지도 장소 ID
Returns: Returns:
편의시설 정보 문자열 또는 None (편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
""" """
facility: str | None = None
homepage: str | None = None
place_types = ["place", "accommodation", "restaurant"] place_types = ["place", "accommodation", "restaurant"]
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for place_type in place_types: for place_type in place_types:
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home" url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
async with session.get(url, headers=self._get_request_headers()) as response: 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="편의") c_elem = soup.find("span", "place_blind", string="편의")
if c_elem: if c_elem:
return c_elem.parent.parent.find("div").string facility = c_elem.parent.parent.find("div").string
return None if facility is not None and homepage is not None:
break
return facility, homepage
except Exception as e: except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
return None return facility, homepage
# if __name__ == "__main__": # if __name__ == "__main__":

View File

@ -90,6 +90,23 @@ async def close_shared_blob_client() -> None:
logger.info("[AzureBlobUploader] Shared HTTP client closed") 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: class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스 """Azure Blob Storage 업로드 클래스

View File

@ -30,6 +30,7 @@ from app.home.api.routers.v1.home import _extract_region_from_address
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
from app.utils.upload_blob_as_request import to_playback_url
from app.database.like_cache import ( from app.database.like_cache import (
backfill_user_set, backfill_user_set,
@ -79,6 +80,55 @@ VIDEO_CREDIT_COST = 1
router = APIRouter(prefix="/video", tags=["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)를 경유해
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
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,
MarketingIntel.official_site_url,
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
)
).all()
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():
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
@router.get( @router.get(
"/generate/{task_id}", "/generate/{task_id}",
@ -892,7 +942,7 @@ async def download_video(
store_name=project.store_name if project else None, 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, region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
task_id=task_id, 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, created_at=video.created_at,
) )
@ -962,7 +1012,7 @@ async def get_all_videos(
type=it.ctype, type=it.ctype,
video_id=it.id, video_id=it.id,
store_name=it.store_name, store_name=it.store_name,
result_movie_url=it.movie_url, result_movie_url=to_playback_url(it.movie_url),
poster_url=it.poster_url, poster_url=it.poster_url,
title=it.title, title=it.title,
description=it.description, description=it.description,
@ -1203,10 +1253,12 @@ async def get_video_detail(
liked = False liked = False
is_liked_by_me = liked 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}") logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
return VideoDetailResponse( return VideoDetailResponse(
video_id=video.id, 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, poster_url=video.poster_url,
store_name=project.store_name, store_name=project.store_name,
region=project.region or _extract_region_from_address(project.detail_region_info), region=project.region or _extract_region_from_address(project.detail_region_info),
@ -1215,6 +1267,7 @@ async def get_video_detail(
created_at=video.created_at, created_at=video.created_at,
like_count=like_count, like_count=like_count,
is_liked_by_me=is_liked_by_me, is_liked_by_me=is_liked_by_me,
official_site_url=official_site_url_map.get(project.id),
) )
except HTTPException: except HTTPException:

View File

@ -207,6 +207,10 @@ class VideoThumbnailItem(BaseModel):
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class VideoDetailResponse(BaseModel): class VideoDetailResponse(BaseModel):
@ -226,6 +230,10 @@ class VideoDetailResponse(BaseModel):
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class LikeToggleResponse(BaseModel): class LikeToggleResponse(BaseModel):