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)", )