import asyncio import random 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 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화) # UA·뷰포트·sec-ch-ua를 한 세트로 묶은 브라우저 프로필 후보군 (안티봇 핑거프린트 회피용) _UA_PROFILES = [ { "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", "sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"', "viewport": {"width": 1280, "height": 720}, }, { "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", "sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="129", "Google Chrome";v="129"', "viewport": {"width": 1366, "height": 768}, }, { "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"', "viewport": {"width": 1440, "height": 900}, }, { "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"', "viewport": {"width": 1536, "height": 864}, }, ] _current_profile = None # instance var page = None @classmethod def _pick_profile(cls): """현재 프로필과 다른 프로필을 무작위로 선택한다 (후보가 1개뿐이면 그대로 반환).""" candidates = [p for p in cls._UA_PROFILES if p is not cls._current_profile] return random.choice(candidates or cls._UA_PROFILES) @classmethod def default_context_builder(cls): cls._current_profile = cls._pick_profile() profile = cls._current_profile context_builder_dict = {} context_builder_dict['viewport'] = dict(profile['viewport']) context_builder_dict['screen'] = dict(profile['viewport']) context_builder_dict['user_agent'] = profile['user_agent'] 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 @classmethod async def _recreate_context(cls): """네이버 안티봇 차단이 의심될 때 브라우저 컨텍스트를 새로 생성해 세션/핑거프린트를 초기화한다.""" old_context = cls._context cls._context = await cls._browser.new_context(**cls.default_context_builder()) if old_context: await old_context.close() 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': self._current_profile['sec_ch_ua'] }) 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 = 5.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 # 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도 logger.warning(f"[BLOCK-SUSPECTED] 3회 시도 모두 실패 → 컨텍스트 재생성 후 재시도: '{title}'") await self.page.close() await self._recreate_context() await self.create_page() result = await self._try_search(address, title) if result: return result logger.error(f"[ERROR] Not found url for {selected}") return None