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로 흐르므로 여기엔 미저장)
|
# 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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -56,6 +56,10 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||||||
microReviews
|
microReviews
|
||||||
conveniences
|
conveniences
|
||||||
visitorReviewsTotal
|
visitorReviewsTotal
|
||||||
|
homepages {
|
||||||
|
repr { url landingUrl isDeadUrl type }
|
||||||
|
etc { url landingUrl isDeadUrl type }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
menus {
|
menus {
|
||||||
name
|
name
|
||||||
@ -112,6 +116,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()
|
||||||
@ -297,9 +302,24 @@ 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 = self._extract_official_site_url(self.base_info)
|
||||||
|
|
||||||
return
|
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]]:
|
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]:
|
||||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,8 @@ async def _get_official_site_urls(
|
|||||||
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
|
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
|
||||||
|
|
||||||
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
|
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
|
||||||
place_id를 찾고, 이를 네이버 플레이스 URL로 변환한다.
|
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
|
||||||
|
place_id 기반 네이버 플레이스 URL로 폴백한다.
|
||||||
"""
|
"""
|
||||||
m_id_by_project: dict[int, int] = {}
|
m_id_by_project: dict[int, int] = {}
|
||||||
for p in projects:
|
for p in projects:
|
||||||
@ -106,17 +107,18 @@ async def _get_official_site_urls(
|
|||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(MarketingIntel.id, MarketingIntel.place_id).where(
|
select(
|
||||||
MarketingIntel.id.in_(set(m_id_by_project.values()))
|
MarketingIntel.id,
|
||||||
)
|
MarketingIntel.place_id,
|
||||||
|
MarketingIntel.official_site_url,
|
||||||
|
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
|
||||||
)
|
)
|
||||||
).all()
|
).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():
|
for project_id, m_id in m_id_by_project.items():
|
||||||
url_by_project[project_id] = _place_id_to_site_url(
|
place_id, site_url = intel_by_m_id.get(m_id, (None, None))
|
||||||
place_id_by_m_id.get(m_id)
|
url_by_project[project_id] = site_url or _place_id_to_site_url(place_id)
|
||||||
)
|
|
||||||
return url_by_project
|
return url_by_project
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -188,7 +188,7 @@ class VideoThumbnailItem(BaseModel):
|
|||||||
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
||||||
official_site_url: Optional[str] = Field(
|
official_site_url: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)",
|
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ class VideoDetailResponse(BaseModel):
|
|||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
official_site_url: Optional[str] = Field(
|
official_site_url: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="업체 공식 페이지 URL (크롤링된 네이버 플레이스 기준, 없으면 null)",
|
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user