feat(crawling): 업체 공식 링크 수집·저장 및 official_site_url 응답 연동
- 네이버 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
This commit is contained in:
parent
61bdc27cd3
commit
1ef8e82638
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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을 호출한다.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user