o2o-castad-backend/app/ssulbox/services/place_service.py

247 lines
9.7 KiB
Python

# -*- coding: utf-8 -*-
"""네이버 지도 업장 정보 조회 (Playwright).
두 가지를 한다:
1. `resolve_place_url` — 업장명(+주소) → place URL.
**castad `NvMapPwScraper.get_place_id_url` 을 재사용**한다(ADO2 자동완성과 동일 경로).
2. `fetch_place_detail` — place URL → 업장명·주소.
생성 시 store_name/region 을 채우는 데 쓴다. place 상세의 `__APOLLO_STATE__`
안 `PlaceDetailBase:{id}` 를 파싱한다.
**후보 목록 검색은 여기 없다.** 예전에는 지도 검색 iframe 을 크롤링해 후보를
받았지만 **17초**가 걸려, ADO2 와 동일하게 네이버 검색 API(`/search/accommodation`,
~0.2초)로 자동완성하고 place URL 은 제출 시 1건만 해석하도록 바꿨다(2026-07-31).
"""
import asyncio
import json
import sys
from playwright.async_api import async_playwright
from app.utils.logger import get_logger
# URL 정규화(단축링크 해석·place_id 추출)는 castad 크롤러 것을 그대로 쓴다.
# 무거운 scrap() 은 쓰지 않는다 — fetch_place_detail docstring 참조.
from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.nvMapScraper import NvMapScraper, URLNotFoundException
logger = get_logger("ssulbox")
DESKTOP_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
)
def _extract_apollo_state(html: str) -> dict | None:
"""HTML 안의 `__APOLLO_STATE__ = { ... };` 객체를 중괄호 균형으로 안전하게 추출."""
i = html.find("__APOLLO_STATE__")
if i < 0:
return None
j = html.find("{", i)
if j < 0:
return None
depth = 0
in_str = False
esc = False
for k in range(j, len(html)):
c = html[k]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
try:
return json.loads(html[j : k + 1])
except Exception:
return None
return None
async def resolve_place_url(
title: str, address: str = "", road_address: str = ""
) -> str | None:
"""업장명(+주소)으로 네이버 지도 place URL 을 해석한다.
**castad `NvMapPwScraper.get_place_id_url` 을 그대로 재사용한다** — ADO2 자동완성이
쓰는 바로 그 경로다(`app/home` 의 `_autocomplete_logic`). allSearch API 응답을
캡처해 이름·주소 유사도로 후보를 고르고, 실패하면 정제주소 → 업체명 단독 →
컨텍스트 재생성 순으로 3단 재시도한다.
썰박스 자체 지도 크롤링(`search_places`)을 쓰지 않는 이유: 그쪽은 후보 목록을
받으려고 iframe 렌더를 기다려 **17초**가 걸린다. ADO2 는 후보 탐색을 네이버
검색 API(0.2초)로 하고 여기서는 확정된 1건만 해석하므로 훨씬 빠르고,
안티봇 차단 대응까지 이미 들어 있다.
실패하면 None. 호출부는 사용자가 링크를 직접 붙여넣는 우회 경로를 남겨둔다.
"""
title = (title or "").strip()
if not title:
return None
try:
selected = {
"title": title,
"address": address or "",
"roadAddress": road_address or address or "",
}
async with NvMapPwScraper() as scraper:
url = await scraper.get_place_id_url(selected)
logger.info(f"[resolve_place_url] {title!r}{url}")
return url
except Exception as e:
logger.error(
f"[resolve_place_url] FAILED {title!r} - {type(e).__name__}: {e}",
exc_info=True,
)
return None
def _detail_from_state(state: dict) -> dict | None:
"""place 상세 페이지 apollo state → {title, category, address, roadAddress}.
실측(2026-07-29, `zzz/_probe_place_detail.py`): 상세는 `pcmap.place.naver.com`
iframe 안에 `PlaceDetailBase:{place_id}` 키로 들어 있고 name/address/roadAddress/
category 를 모두 갖는다. iframe 경로에 업종 세그먼트가 끼므로
(`/restaurant/{id}/home`) 경로를 고정하면 안 된다.
"""
for key, obj in state.items():
if not key.startswith("PlaceDetailBase:") or not isinstance(obj, dict):
continue
name = (obj.get("name") or "").strip()
if not name:
continue
return {
"title": name,
"category": (obj.get("category") or "").strip(),
"address": (obj.get("address") or "").strip(),
"roadAddress": (obj.get("roadAddress") or "").strip(),
}
return None
async def _extract_detail_from_page(page, tries: int = 12) -> dict | None:
"""열려 있는 place 상세 페이지에서 업장 정보를 뽑는다(iframe 탐색 + 재시도)."""
for _ in range(tries):
for frame in [page, *[f for f in page.frames if "pcmap" in f.url]]:
try:
html = await frame.content()
except Exception:
continue
state = _extract_apollo_state(html)
if not state:
continue
detail = _detail_from_state(state)
if detail:
# 공식 링크는 apollo state 의 파싱된 dict 가 아니라 원문 HTML 에서 뽑는다
# (`homepages` 는 GraphQL placeDetail 이 노출하지 않아 castad 도 같은 방식).
# 이미 받아 둔 html 을 재사용하므로 추가 요청·왕복이 없다.
detail["homepage"] = NvMapScraper._extract_homepage_from_html(html)
return detail
await page.wait_for_timeout(700)
return None
async def _detail(place_url: str) -> dict | None:
"""place URL 하나를 열어 업장명·주소를 수집."""
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
)
try:
ctx = await browser.new_context(
user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul",
viewport={"width": 1280, "height": 800},
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"},
)
page = await ctx.new_page()
await page.goto(place_url, wait_until="domcontentloaded", timeout=40000)
detail = await _extract_detail_from_page(page)
if detail:
detail["place_url"] = page.url
return detail
finally:
await browser.close()
def _detail_blocking(place_url: str) -> dict | None:
"""`_search_blocking` 과 같은 이유로 스레드에서 자체 루프를 쓴다."""
loop = (
asyncio.ProactorEventLoop() if sys.platform == "win32"
else asyncio.new_event_loop()
)
try:
return loop.run_until_complete(_detail(place_url))
finally:
loop.close()
async def fetch_place_detail(
place_url: str, timeout: float = 45.0
) -> dict | None:
"""place URL 로 업장명·주소를 수집. 실패하면 None.
**업장명과 주소는 항상 함께 수집한다** — 주소가 없으면 지역(region)을 못 만들고,
그러면 통합 콘텐츠 목록의 지역 필터에서 그 콘텐츠가 영구히 제외된다.
URL 정규화는 castad `NvMapScraper.parse_url()` 을 **재사용**한다.
`naver.me` 단축링크를 브라우저 없이 HTTP 리다이렉트로 풀고
`place.naver.com/{업종}/{id}` 형식도 처리하므로, ADO2 크롤링과 **같은 URL 형식**을
받아들이게 된다. 형식이 아예 아니면 브라우저를 띄우기 전에 즉시 포기한다.
반면 `NvMapScraper.scrap()` 은 쓰지 않는다 — 사진 다중 페이지·리뷰 통계·
편의시설·메뉴까지 전부 긁어오므로 이름·주소만 필요한 여기에는 과하다.
실패해도 예외를 올리지 않는다: 이 정보는 목록 표시·필터용 부가 정보이고,
생성 자체는 place_url 만으로 진행되므로 크롤링 실패가 생성을 막아선 안 된다.
"""
place_url = (place_url or "").strip()
if not place_url:
return None
# 단축링크 해석 + place_id 추출 (브라우저 없이). 실패해도 원본 URL 로 계속 간다.
try:
place_id = await NvMapScraper(place_url).parse_url()
place_url = f"https://map.naver.com/p/entry/place/{place_id}"
except URLNotFoundException:
logger.warning(f"[fetch_place_detail] place URL 아님 - {place_url}")
return None
except Exception as e:
logger.info(
f"[fetch_place_detail] URL 정규화 실패, 원본으로 진행 - "
f"{type(e).__name__}: {e}"
)
try:
detail = await asyncio.wait_for(
asyncio.to_thread(_detail_blocking, place_url), timeout=timeout
)
if detail:
logger.info(
f"[fetch_place_detail] {place_url}"
f"title={detail['title']!r} road={detail['roadAddress']!r}"
)
else:
logger.warning(f"[fetch_place_detail] 정보 없음 - {place_url}")
return detail
except asyncio.TimeoutError:
logger.warning(f"[fetch_place_detail] TIMEOUT ({timeout}s) - {place_url}")
return None
except Exception as e:
logger.error(
f"[fetch_place_detail] FAILED {place_url} - {type(e).__name__}: {e}",
exc_info=True,
)
return None