"""네이버 쇼핑 크롤 어댑터 (모바일 msearch). 배경: 2026-07-31 네이버가 쇼핑 검색 오픈API 를 종료(404 SE05)했고 NCP API HUB 에도 승계되지 않았다 → 가격을 얻을 공식 경로가 없어 크롤로 돌아왔다. 경로 선택(2026-08-04 실측): PC search.shopping.naver.com/search/all 405 + WTM 캡차 PC /api/search/all 418(봇 차단) 모바일 msearch.shopping.naver.com **200 · 카드 40건** ← 이 경로 7/9 스파이크 때 모바일은 로그인 리다이렉트였는데 그 사이 열렸다. 다시 닫힐 수 있으므로 차단 마커를 넉넉히 잡고, 막히면 IP 회전(쿠팡과 동일한 BrowserSearchAdapter machinery)에 맡긴다. 프록시: **한국 IP 필수**(worker_main 이 kr.decodo.com 게이트웨이로 주입). 실측 차이가 크다 — 해외 residential IP 즉시 하드차단(2.6KB 'Access Denied' 계열) 한국 residential IP 정상. 단일 IP 로 연달아 두드리면 캡차로 넘어가므로 회전은 그대로 필요 """ from urllib.parse import quote from services.search.browser_base import BrowserSearchAdapter, detect_block as _detect_block from services.search.contract import NormalizedProduct from services.search.naver_shop.parser import parse_search_html from services.search.naver_shop.selectors import SELECTORS _SEARCH_URL = "https://msearch.shopping.naver.com/search/all?query={q}" # 차단 flavor: WTM 캡차 페이지 / 접근제한 안내 / 418 teapot 본문. _BLOCK_MARKERS = ( "wtm_captcha", "비정상적인 접근", "일시적으로 제한", "자동입력 방지", "정상적인 서비스 이용", "nid.naver.com/nidlogin", ) # 해외 IP 로 접근했을 때만 나오는 하드차단(실측 2,641B). IP 를 바꿔도 같은 게이트웨이면 # 결과가 같으므로 회전·재시도가 무의미하다 — kr_host 설정을 고쳐야 한다. _FATAL_MARKERS = ("비정상적인 접근",) # 정상 결과 페이지는 1.3MB+ 다. 차단 페이지는 실측 48~65KB → 그 사이에 임계를 둔다. # (0건일 때만 적용되므로 '검색결과 없음'이 커도 오탐하지 않는다) _MIN_RESULT_HTML = 150_000 def detect_block(html: str, product_count: int) -> str | None: """0건 응답의 차단 여부 판정(순수 함수 — 브라우저 무관, 단위 테스트 가능).""" return _detect_block(html, product_count, _BLOCK_MARKERS, _MIN_RESULT_HTML) class NaverShopAdapter(BrowserSearchAdapter): # source 는 구현(API/크롤)이 아니라 **도메인 정체성**이다. "naver" 를 유지해야 # price_history 의 naver_lowest/name/url, MALL_BY_SOURCE, 프론트 그래프 계약이 그대로 산다. source = "naver" block_markers = _BLOCK_MARKERS fatal_block_markers = _FATAL_MARKERS min_result_html = _MIN_RESULT_HTML ready_selector = SELECTORS.card ready_timeout_ms = 20000 # 모바일은 무한스크롤 — 초기 20건, 스크롤하면 40건까지 늘고 그 이상은 안 나온다(실측). scroll_steps = 3 # ⚠️ 리소스 차단을 **켜면 안 된다**. route 를 걸면 WTM 이 즉시 캡차로 넘긴다(실측 2026-08-04): # 차단 없음 → 정상 14건 · 전송 3.07MB # image/media/font 만 차단 → 차단 · 0.58MB ← CSS 를 살려도 안 통한다 # image/media/font/css 차단 → 차단 # 부분 차단조차 막히는 걸 보면 '무엇을 막느냐'가 아니라 **요청 가로채기(CDP Fetch) 자체**가 # 탐지 신호다. 그래서 대역폭을 포기하고 끈다 — 검색당 ~3MB(DECODO $3/GB 기준 ~$0.009). block_resources_default = False # 한국어 로케일/시간대 필수. 한국 IP 로 들어가면서 브라우저가 en-US 면 WTM 이 캡차를 띄운다 # (실측: 같은 KR 프록시·같은 브라우저에서 이 두 줄 유무로 캡차↔정상이 갈렸다). context_options = {"locale": "ko-KR", "timezone_id": "Asia/Seoul"} def _search_url(self, query: str, limit: int) -> str: return _SEARCH_URL.format(q=quote(query)) def _parse(self, html: str) -> list[NormalizedProduct]: return parse_search_html(html, source=self.source)