**회수율**: 베이스 _wait_ready 는 '고정 3회 스크롤 → 셀렉터 대기' 순서라, 프록시 지연이 있으면 **아직 아무것도 안 그려진 화면을 스크롤**하고 끝났다. 네이버용으로 순서를 뒤집고 종료 조건을 횟수가 아니라 '카드 수가 더 안 늘어남'으로 바꿨다 — 네트워크가 느리든 빠르든 같은 결과가 나온다. A/B(같은 IP·같은 세션, 3개 쿼리): 14·14·20 = 48건 → **40·40·40 = 120건**(전부 상한 도달). **크롤 프리플라이트**: 웜업 대상에 naver 를 추가하고, 3회 모두 실패하면 로그가 아니라 **알림**을 쏜다. 컨테이너 워커는 크롤이 막혀도 하트비트가 살아 있어 healthy 로 보이고, 잡이 DEAD 로 쌓일 때까지 아무도 모른다(실측). 성공하면 해소 알림으로 자동 정리된다. AlertManager 를 main 에서 만들어 웜업·ops 모니터가 쿨다운 상태를 공유한다. **문서**: operations 에 차단 마커별 대응표(비정상적인 접근=구조적/wtm_captcha=회전)와 '컨테이너 크롤 차단' 절 추가 — 배제한 원인, Rosetta 에뮬 주의(= '이 맥에서만'일 수 있음), 배포 시 확인 순서(warmup 로그 → 호스트 비교 → 워커만 호스트 실행). 테스트 3건 추가(웜업 실패 알림·성공 해소·비크롤 소스 스킵), 전체 223 passed·0 failed.
104 lines
5.8 KiB
Python
104 lines
5.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",
|
|
)
|
|
# 해외 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
|
|
# 무한스크롤은 _wait_ready 를 직접 구현해 처리한다(베이스의 고정 횟수 스크롤은 쓰지 않는다).
|
|
scroll_steps = 0
|
|
max_scrolls = 6 # 초기 20건 → 40건에서 멈춘다(실측). 여유 있게 6회면 충분
|
|
scroll_wait_ms = 1500 # 프록시 경유라 렌더가 느리다 — 한 번에 다 안 붙는다
|
|
# ⚠️ 리소스 차단을 **켜면 안 된다**. 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"}
|
|
|
|
async def _wait_ready(self, page):
|
|
"""첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다.
|
|
|
|
베이스 구현은 '고정 횟수 스크롤 → 셀렉터 대기' 순서라 프록시 지연이 있으면
|
|
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝난다(실측: 40건 나올 페이지에서 14건).
|
|
그래서 순서를 뒤집고, 횟수가 아니라 '더 안 늘어남'을 종료 조건으로 둔다 —
|
|
네트워크가 느리든 빠르든 같은 결과를 얻는다.
|
|
"""
|
|
try:
|
|
await page.wait_for_selector(self.ready_selector, timeout=self.ready_timeout_ms)
|
|
except Exception:
|
|
return # 카드가 아예 없음 → 차단 판정(마커/짧은HTML)에 맡긴다
|
|
|
|
count_js = f"() => document.querySelectorAll('{SELECTORS.card}').length"
|
|
prev = -1
|
|
for _ in range(self.max_scrolls):
|
|
try:
|
|
n = await page.evaluate(count_js)
|
|
except Exception:
|
|
return
|
|
if n == prev: # 스크롤해도 안 늘면 끝(더 기다릴 이유 없음)
|
|
return
|
|
prev = n
|
|
try:
|
|
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
except Exception:
|
|
return
|
|
await page.wait_for_timeout(self.scroll_wait_ms)
|
|
|
|
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)
|