441 lines
17 KiB
Python
441 lines
17 KiB
Python
import asyncio
|
||
import re
|
||
from html import unescape
|
||
from difflib import SequenceMatcher
|
||
from playwright.async_api import async_playwright
|
||
from urllib import parse
|
||
import time
|
||
from app.utils.logger import get_logger
|
||
|
||
# 로거 설정
|
||
logger = get_logger("pwscraper")
|
||
|
||
class NvMapPwScraper():
|
||
# cls vars
|
||
is_ready = False
|
||
_playwright = None
|
||
_browser = None
|
||
_context = None
|
||
_win_width = 1280
|
||
_win_height = 720
|
||
_max_retry = 3
|
||
_timeout = 30 # place id timeout threshold seconds
|
||
_retry_delay_ms = 1500 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화)
|
||
|
||
# instance var
|
||
page = None
|
||
|
||
@classmethod
|
||
def default_context_builder(cls):
|
||
context_builder_dict = {}
|
||
context_builder_dict['viewport'] = {
|
||
'width' : cls._win_width,
|
||
'height' : cls._win_height
|
||
}
|
||
context_builder_dict['screen'] = {
|
||
'width' : cls._win_width,
|
||
'height' : cls._win_height
|
||
}
|
||
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||
context_builder_dict['locale'] = 'ko-KR'
|
||
context_builder_dict['timezone_id']='Asia/Seoul'
|
||
|
||
return context_builder_dict
|
||
|
||
@classmethod
|
||
async def initiate_scraper(cls):
|
||
if not cls._playwright:
|
||
cls._playwright = await async_playwright().start()
|
||
if not cls._browser:
|
||
cls._browser = await cls._playwright.chromium.launch(headless=True)
|
||
if not cls._context:
|
||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||
cls.is_ready = True
|
||
|
||
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
|
||
|
||
@classmethod
|
||
async def fetch_graphql(
|
||
cls,
|
||
place_id: str,
|
||
payloads: list[dict],
|
||
) -> list[dict | None] | None:
|
||
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
||
|
||
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
||
- x-wtm-graphql : base64({"arg": place_id, "type": ..., "source": "place"})
|
||
- x-wtm-ncaptcha-token : 캡차 JS가 생성 (요청마다 발급, 직접 생성 불가)
|
||
둘 다 없으면 405(캡차)로 막힌다. 따라서 place 페이지를 실제로 로드해
|
||
페이지가 자연 발생시키는 GraphQL 요청에서 두 헤더를 캡처한 뒤,
|
||
같은 토큰으로 우리 쿼리들을 in-page fetch로 실행한다.
|
||
|
||
Args:
|
||
place_id: 네이버 place ID
|
||
payloads: GraphQL POST 본문 목록
|
||
Returns:
|
||
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
|
||
"""
|
||
if not cls.is_ready:
|
||
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
|
||
return None
|
||
|
||
page = await cls._context.new_page()
|
||
captured: dict = {}
|
||
|
||
def on_request(req):
|
||
if "/graphql" in req.url and req.method == "POST" and "tok" not in captured:
|
||
tok = req.headers.get("x-wtm-ncaptcha-token")
|
||
if tok:
|
||
captured["tok"] = tok
|
||
captured["wtm"] = req.headers.get("x-wtm-graphql")
|
||
|
||
page.on("request", on_request)
|
||
|
||
try:
|
||
for t in ("place", "restaurant", "accommodation"):
|
||
try:
|
||
await page.goto(
|
||
f"https://pcmap.place.naver.com/{t}/{place_id}/home",
|
||
wait_until="domcontentloaded",
|
||
timeout=30000,
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"[NvMapPwScraper] goto {t} 실패: {e}")
|
||
continue
|
||
# 페이지가 GraphQL 요청을 보내며 토큰이 헤더에 실릴 때까지 대기 (최대 ~9초)
|
||
for _ in range(30):
|
||
if captured.get("tok"):
|
||
break
|
||
await page.wait_for_timeout(300)
|
||
if captured.get("tok"):
|
||
logger.info(f"[NvMapPwScraper] WTM 토큰 캡처 성공 (type={t})")
|
||
break
|
||
|
||
if not captured.get("tok"):
|
||
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
|
||
return None
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"x-wtm-graphql": captured["wtm"],
|
||
"x-wtm-ncaptcha-token": captured["tok"],
|
||
}
|
||
|
||
results: list[dict | None] = []
|
||
for idx, payload in enumerate(payloads, start=1):
|
||
r = await page.evaluate(
|
||
"""async (a) => {
|
||
const [url, body, hdr] = a;
|
||
try {
|
||
const r = await fetch(url, {
|
||
method: 'POST',
|
||
headers: hdr,
|
||
body: JSON.stringify(body),
|
||
credentials: 'include',
|
||
});
|
||
if (r.status !== 200) return {__err: r.status};
|
||
return await r.json();
|
||
} catch (e) { return {__err: String(e)}; }
|
||
}""",
|
||
[cls.GRAPHQL_URL, payload, headers],
|
||
)
|
||
if isinstance(r, dict) and "__err" in r:
|
||
op = payload.get("operationName", "?")
|
||
logger.warning(
|
||
f"[NvMapPwScraper] graphql fetch 실패 "
|
||
f"({idx}/{len(payloads)} {op}): {r['__err']}"
|
||
)
|
||
results.append(None)
|
||
else:
|
||
results.append(r)
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
||
return None
|
||
finally:
|
||
await page.close()
|
||
|
||
def __init__(self):
|
||
if not self.is_ready:
|
||
raise Exception("nvMapScraper is not initiated")
|
||
|
||
async def __aenter__(self):
|
||
await self.create_page()
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb):
|
||
await self.page.close()
|
||
|
||
async def create_page(self):
|
||
self.page = await self._context.new_page()
|
||
await self.page.add_init_script(
|
||
'''const defaultGetter = Object.getOwnPropertyDescriptor(
|
||
Navigator.prototype,
|
||
"webdriver"
|
||
).get;
|
||
defaultGetter.apply(navigator);
|
||
defaultGetter.toString();
|
||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||
set: undefined,
|
||
enumerable: true,
|
||
configurable: true,
|
||
get: new Proxy(defaultGetter, {
|
||
apply: (target, thisArg, args) => {
|
||
Reflect.apply(target, thisArg, args);
|
||
return false;
|
||
},
|
||
}),
|
||
});
|
||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||
Navigator.prototype,
|
||
"webdriver"
|
||
).get;
|
||
patchedGetter.apply(navigator);
|
||
patchedGetter.toString();''')
|
||
|
||
await self.page.set_extra_http_headers({
|
||
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||
})
|
||
await self.page.goto("http://google.com")
|
||
|
||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||
page = self.page
|
||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||
|
||
@staticmethod
|
||
def _clean_title(text: str) -> str:
|
||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
||
return text.strip()
|
||
|
||
@staticmethod
|
||
def _similarity(a: str, b: str) -> float:
|
||
return SequenceMatcher(None, a, b).ratio()
|
||
|
||
@staticmethod
|
||
def _refine_address(address: str) -> str:
|
||
"""한국 주소 패턴에서 첫 번째 유효한 주소만 추출한다."""
|
||
patterns = [
|
||
# 도로명 (정식): 경기도 가평군 운악로 278
|
||
re.compile(
|
||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
||
),
|
||
# 지번 (정식): 경기도 가평군 조종면 운악리 278
|
||
re.compile(
|
||
r'[가-힣]+(?:특별시|광역시|특별자치시|도|특별자치도|시)\s+'
|
||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
||
),
|
||
# 도로명 (축약): 경기 가평 운악로 278
|
||
re.compile(
|
||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
||
r'[가-힣\s]+?(?:로|길|대로)\s+\d+(?:-\d+)?'
|
||
),
|
||
# 지번 (축약): 경기 가평 조종면 운악리 278
|
||
re.compile(
|
||
r'[가-힣]{1,4}\s+[가-힣]{1,6}\s+'
|
||
r'[가-힣\s]+?(?:읍|면|동|리|가)\s+\d+(?:-\d+)?'
|
||
),
|
||
]
|
||
for pattern in patterns:
|
||
m = pattern.search(address)
|
||
if m:
|
||
return m.group().strip()
|
||
return address
|
||
|
||
async def _extract_candidates_from_list_page(self) -> list[dict]:
|
||
"""pcmap.place.naver.com iframe HTML에서 place ID와 업체명을 추출한다."""
|
||
pcmap_frame = None
|
||
for frame in self.page.frames:
|
||
if "pcmap.place.naver.com" in frame.url:
|
||
pcmap_frame = frame
|
||
logger.debug(f"[DEBUG] pcmap frame 발견: {frame.url[:80]}")
|
||
break
|
||
|
||
if not pcmap_frame:
|
||
logger.debug("[DEBUG] pcmap frame 없음")
|
||
return []
|
||
|
||
try:
|
||
html = await pcmap_frame.content()
|
||
except Exception as e:
|
||
logger.debug(f"[DEBUG] pcmap frame content 추출 실패: {e}")
|
||
return []
|
||
|
||
# {"id":"11659052","name":"프레지던트 호텔",...} 형태의 JSON 쌍 추출
|
||
pair_pattern = re.compile(
|
||
r'"id"\s*:\s*"(\d{5,})"[^}]{0,200}?"name"\s*:\s*"([^"]{1,60})"'
|
||
r'|"name"\s*:\s*"([^"]{1,60})"[^}]{0,200}?"id"\s*:\s*"(\d{5,})"'
|
||
)
|
||
|
||
seen = {} # place_id → title (순서 보존)
|
||
for m in pair_pattern.finditer(html):
|
||
if m.group(1): # id 먼저
|
||
pid, title = m.group(1), m.group(2)
|
||
else: # name 먼저
|
||
pid, title = m.group(4), m.group(3)
|
||
if pid not in seen:
|
||
seen[pid] = title
|
||
|
||
candidates = [
|
||
{"title": title, "place_url": f"https://map.naver.com/p/entry/place/{pid}"}
|
||
for pid, title in list(seen.items())[:10]
|
||
]
|
||
|
||
for i, c in enumerate(candidates):
|
||
logger.debug(f"[DEBUG] 후보 {i+1}: {c['title']} / {c['place_url']}")
|
||
|
||
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
|
||
return candidates
|
||
|
||
@staticmethod
|
||
def _parse_allsearch_candidates(body: dict) -> list[dict]:
|
||
"""allSearch 응답 JSON에서 후보 목록을 추출한다."""
|
||
place_list = (((body.get("result") or {}).get("place") or {}).get("list")) or []
|
||
return [
|
||
{
|
||
"title": p.get("name") or "",
|
||
"place_url": f"https://map.naver.com/p/entry/place/{p['id']}",
|
||
"roadAddress": p.get("roadAddress") or "",
|
||
"address": p.get("address") or "",
|
||
}
|
||
for p in place_list
|
||
if p.get("id")
|
||
]
|
||
|
||
def _select_best_candidate(self, candidates: list[dict], title: str, address: str) -> dict | None:
|
||
"""이름 유사도(70%)와 주소 유사도(30%)를 함께 고려해 최적 후보를 선택한다.
|
||
|
||
주소 없이 업체명만으로 검색한 경우(3차 폴백)는 이름 유사도만 사용한다.
|
||
"""
|
||
if not candidates:
|
||
return None
|
||
|
||
for c in candidates:
|
||
name_score = self._similarity(title, self._clean_title(c["title"]))
|
||
cand_addr = c.get("roadAddress") or c.get("address") or ""
|
||
addr_score = self._similarity(address, cand_addr) if address and cand_addr else 0.0
|
||
c["_name_score"] = name_score
|
||
c["_addr_score"] = addr_score
|
||
c["_total_score"] = name_score * 0.7 + addr_score * 0.3 if address else name_score
|
||
|
||
return max(candidates, key=lambda c: c["_total_score"])
|
||
|
||
async def _capture_allsearch(self, url: str, timeout_s: float = 8.0) -> list[dict]:
|
||
"""검색 페이지가 자연 발생시키는 allSearch API 응답을 캡처해 후보 목록으로 변환한다.
|
||
|
||
pcmap iframe 렌더링을 기다릴 필요가 없어 경쟁 조건이 없고, 업체명 단독
|
||
검색처럼 iframe 자체가 생성되지 않는 케이스에서도 동작한다.
|
||
"""
|
||
captured: list[dict] = []
|
||
|
||
def on_response(resp):
|
||
# GET 요청의 실제 응답만 캡처 (OPTIONS preflight 등 오탐 방지)
|
||
if "allSearch" in resp.url and resp.request.method == "GET":
|
||
async def _consume():
|
||
try:
|
||
captured.append(await resp.json())
|
||
except Exception:
|
||
pass
|
||
asyncio.create_task(_consume())
|
||
|
||
self.page.on("response", on_response)
|
||
try:
|
||
try:
|
||
await self.goto_url(url, wait_until="domcontentloaded", timeout=self._timeout * 1000)
|
||
except Exception:
|
||
logger.error("[ERROR] Can't Finish domcontentloaded")
|
||
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline:
|
||
if "/place/" in self.page.url or captured:
|
||
break
|
||
await self.page.wait_for_timeout(200)
|
||
finally:
|
||
self.page.remove_listener("response", on_response)
|
||
|
||
if not captured:
|
||
return []
|
||
return self._parse_allsearch_candidates(captured[0])
|
||
|
||
async def _try_search(self, address: str, title: str) -> str | None:
|
||
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
|
||
encoded_query = parse.quote(f"{address} {title}".strip())
|
||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||
|
||
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
|
||
candidates = await self._capture_allsearch(url)
|
||
if "/place/" in self.page.url:
|
||
return self.page.url
|
||
|
||
if candidates:
|
||
best = self._select_best_candidate(candidates, title, address)
|
||
logger.info(
|
||
f"[AUTO-SELECT] '{title}' → '{best['title']}' "
|
||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
||
)
|
||
return best['place_url']
|
||
|
||
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
|
||
logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
|
||
iframe_candidates = []
|
||
for _ in range(3): # 500ms × 3 = 최대 1.5초
|
||
if "/place/" in self.page.url:
|
||
return self.page.url
|
||
iframe_candidates = await self._extract_candidates_from_list_page()
|
||
if iframe_candidates:
|
||
break
|
||
await self.page.wait_for_timeout(500)
|
||
|
||
if iframe_candidates:
|
||
best = self._select_best_candidate(iframe_candidates, title, address)
|
||
logger.info(
|
||
f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
|
||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
||
)
|
||
return best['place_url']
|
||
|
||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
||
try:
|
||
await self.goto_url(correct_url, wait_until="networkidle", timeout=self._timeout * 1000)
|
||
except:
|
||
if "/place/" in self.page.url:
|
||
return self.page.url
|
||
logger.error("[ERROR] Can't Finish networkidle (isCorrectAnswer)")
|
||
|
||
if "/place/" in self.page.url:
|
||
return self.page.url
|
||
|
||
return None
|
||
|
||
async def get_place_id_url(self, selected):
|
||
title = self._clean_title(selected['title'])
|
||
address = self._clean_title(selected.get('roadAddress', selected['address']))
|
||
|
||
# 1차 시도: 원본 주소 + 업체명
|
||
logger.debug(f"[DEBUG] 1차 시도 - address: {address}")
|
||
result = await self._try_search(address, title)
|
||
if result:
|
||
return result
|
||
|
||
# 2차 시도: 정제 주소 + 업체명
|
||
refined = self._refine_address(address)
|
||
if refined != address:
|
||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
||
result = await self._try_search(refined, title)
|
||
if result:
|
||
return result
|
||
|
||
# 3차 시도: 업체명만으로 검색
|
||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
||
result = await self._try_search("", title)
|
||
if result:
|
||
return result
|
||
|
||
logger.error(f"[ERROR] Not found url for {selected}")
|
||
return None
|