328 lines
13 KiB
Python
328 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""네이버 지도 업장 검색 (키 불필요, Playwright).
|
|
|
|
`map.naver.com/p/search/{query}` 의 검색 리스트 iframe(`pcmap.place.naver.com/place/list`)
|
|
안에 있는 `__APOLLO_STATE__` 를 파싱해, 동명 업장들을 주소로 구분한 후보 목록을 만든다.
|
|
각 후보는 `place_url`(map.naver.com/p/entry/place/{id})을 가지므로, 사용자가 하나를 고르면
|
|
그 URL 을 그대로 생성 파이프라인(generator/naver.py)에 넘겨 정확히 그 가게를 크롤링한다.
|
|
|
|
**castad `/search/accommodation` 으로 대체할 수 없다.** 그쪽은 네이버 *검색 API* 라
|
|
`title`/`address`/`roadAddress` 만 주고 `place_url` 이 없는데, generator 가 place 페이지를
|
|
크롤링하므로 URL 이 반드시 필요하다. (castad `NvMapPwScraper` 는 후보=NAVER API,
|
|
place_id 해석=Playwright 로 2단계를 밟지만 여기서는 지도만으로 한 번에 얻는다.)
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from urllib.parse import quote
|
|
|
|
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.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
|
|
|
|
|
|
def _candidates_from_state(state: dict, limit: int) -> list[dict]:
|
|
out: list[dict] = []
|
|
for key, obj in state.items():
|
|
if not key.startswith("PlaceListBusinessesItem:"):
|
|
continue
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
name = obj.get("name")
|
|
pid = obj.get("id")
|
|
if not (name and pid and str(pid).isdigit()):
|
|
continue
|
|
out.append(
|
|
{
|
|
"title": name,
|
|
"category": obj.get("category") or "",
|
|
# 표시용 주소는 '서울 성동구 금호동3가' 같은 commonAddress 를 우선(동명 구분에 최적)
|
|
"address": obj.get("commonAddress") or obj.get("fullAddress") or "",
|
|
"roadAddress": obj.get("fullAddress") or obj.get("roadAddress") or "",
|
|
"place_url": f"https://map.naver.com/p/entry/place/{pid}",
|
|
}
|
|
)
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
|
|
async def _search(query: str, limit: int) -> list[dict]:
|
|
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(
|
|
f"https://map.naver.com/p/search/{quote(query)}",
|
|
wait_until="domcontentloaded", timeout=40000,
|
|
)
|
|
# 검색 리스트 iframe 이 뜰 때까지 대기(최대 ~12초)
|
|
frame = None
|
|
for _ in range(24):
|
|
for f in page.frames:
|
|
if "pcmap.place.naver.com/place/list" in f.url:
|
|
frame = f
|
|
break
|
|
if frame:
|
|
break
|
|
# 단일 결과면 곧바로 place 상세로 리다이렉트됨 → 후보 1개로 처리.
|
|
# 이때도 **주소를 반드시 채운다**. 예전에는 빈 문자열을 돌려줬는데,
|
|
# 그러면 프론트가 주소를 못 보내고 지역(region)이 NULL 이 되어
|
|
# 그 콘텐츠가 통합 목록의 지역 필터에서 영구 제외된다.
|
|
if "/place/" in page.url and "/search/" not in page.url:
|
|
detail = await _extract_detail_from_page(page)
|
|
if detail:
|
|
detail["place_url"] = page.url
|
|
return [detail]
|
|
# 상세 파싱까지 실패하면 최소 정보라도 준다(생성은 가능해야 한다)
|
|
return [{"title": query, "category": "", "address": "",
|
|
"roadAddress": "", "place_url": page.url}]
|
|
await page.wait_for_timeout(500)
|
|
if not frame:
|
|
return []
|
|
# apollo state 가 채워질 시간을 조금 더 준다
|
|
html = await frame.content()
|
|
state = _extract_apollo_state(html)
|
|
for _ in range(6):
|
|
if state and any(k.startswith("PlaceListBusinessesItem:") for k in state):
|
|
break
|
|
await page.wait_for_timeout(600)
|
|
html = await frame.content()
|
|
state = _extract_apollo_state(html)
|
|
if not state:
|
|
return []
|
|
return _candidates_from_state(state, limit)
|
|
finally:
|
|
await browser.close()
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
def _search_blocking(query: str, limit: int) -> list[dict]:
|
|
"""별도 스레드에서 자체 이벤트 루프로 Playwright 실행.
|
|
|
|
Windows 의 웹서버 루프(Selector)는 서브프로세스를 못 띄워 Playwright 가 즉시 실패한다.
|
|
→ 이 함수는 워커 스레드에서 Proactor 루프(윈도우) 를 새로 만들어 그 위에서 돌린다.
|
|
"""
|
|
if sys.platform == "win32":
|
|
loop = asyncio.ProactorEventLoop()
|
|
else:
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(_search(query, limit))
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
async def search_places(query: str, limit: int = 8, timeout: float = 35.0) -> list[dict]:
|
|
"""업장명으로 네이버 지도 후보 목록을 반환. 실패/타임아웃이면 빈 리스트.
|
|
|
|
실패해도 예외를 올리지 않는다 — 사용자에게는 네이버 링크를 직접 붙여넣는
|
|
우회 경로가 있으므로 흐름을 막지 않는다. 다만 원본은 예외를 통째로 삼켜
|
|
디버깅이 불가능했으므로 로그는 남긴다.
|
|
"""
|
|
query = (query or "").strip()
|
|
if len(query) < 2:
|
|
return []
|
|
try:
|
|
results = await asyncio.wait_for(
|
|
asyncio.to_thread(_search_blocking, query, limit), timeout=timeout
|
|
)
|
|
logger.info(f"[search_places] query='{query}' → {len(results)}건")
|
|
return results
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"[search_places] TIMEOUT ({timeout}s) query='{query}'")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[search_places] FAILED query='{query}' - {type(e).__name__}: {e}",
|
|
exc_info=True,
|
|
)
|
|
return []
|