Merge branch 'main' into feature-ssulbox: SAS 재생 URL·공식 링크 응답 반영
This commit is contained in:
commit
934410a83b
@ -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,
|
||||||
|
|||||||
@ -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__,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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__":
|
||||||
|
|||||||
@ -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 업로드 클래스
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user