shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아
가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환.
경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은
로그인 리다이렉트였는데 그 사이 열렸다.
통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측):
- **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이
([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지)
- **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다
(BrowserSearchAdapter.context_options 훅 추가)
- **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 →
'무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009)
파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다:
- 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격)
- 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘)
- 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미)
- **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다
- 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율
source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이
구현(API→크롤) 교체와 무관하게 살아야 한다.
테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed.
68 lines
3.8 KiB
Python
68 lines
3.8 KiB
Python
"""네이버 쇼핑 크롤 어댑터 (모바일 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",
|
|
)
|
|
# 정상 결과 페이지는 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
|
|
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)
|