o2o-castad-backend/app/utils/nvMapPwScraper.py

354 lines
13 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 = 60 # place id timeout threshold seconds
# 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 payload in payloads:
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:
logger.warning(f"[NvMapPwScraper] graphql fetch 실패: {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
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}"
try:
await self.goto_url(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")
if "/place/" in self.page.url:
return self.page.url
candidates = await self._extract_candidates_from_list_page()
if candidates:
best = max(
candidates,
key=lambda c: self._similarity(title, self._clean_title(c['title']))
)
best_score = self._similarity(title, self._clean_title(best['title']))
logger.info(
f"[AUTO-SELECT] '{title}''{best['title']}' (score={best_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:
logger.info(f"[REFINE] 주소 정제: '{address}''{refined}'")
result = await self._try_search(refined, title)
if result:
return result
# 3차 시도: 업체명만으로 검색
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