컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **UA 가 리눅스라고 말하는 것**이 원인이다. 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다. 실측(같은 이미지·같은 KR 프록시, 서로 다른 IP 3개씩): X11; Linux x86_64 0/3 통과 전부 405 + wtm_captcha (~50KB) Macintosh; Intel Mac 3/3 통과 전부 200 · 6건 · ~1.0MB 안드로이드·아이폰 모바일 0/2 418 '비정상적인 접근'(2.6KB, 회전 무효 하드차단) 프록시 없이 같은 집 IP 로도 호스트 통과 / 컨테이너 차단이 재현돼 IP·게이트웨이는 배제됐다. 맥에서 잘 되던 이유도 이걸로 설명된다. **405 가 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 그동안 의심하던 WebGL·폰트·plugins 는 애초에 원인이 될 수 없었다(확인차 --enable-unsafe-swiftshader 로 WebGL 을 살려봤지만 405 그대로였다). 조치: - services/search/user_agent.py: 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환. Chrome 버전은 `--version` 으로 실제 값을 읽어 유지한다 — 하드코딩하면 컨테이너 Chrome 업데이트 시 UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 조회 실패해도 크롤을 막지 않는다. - NaverShopAdapter.mac_ua_on_linux = True (쿠팡은 잘 통과하므로 기본 False 그대로 — 멀쩡한 걸 건드리지 않는다). 맥/윈도우에서는 자동 미적용. - 실제 어댑터로 컨테이너 검증: '생수' 40건, '스페셜티 원두 1kg' 40건 통과. 부수: - fingerprint.judge: WebGL 이 **아예 없는** 경우를 OK 로 흘려보내던 판정 버그 수정(컨테이너 재현 중 발견 — 소프트웨어 렌더링보다 더 튀는 값인데 침묵했다). UA 플랫폼 항목 추가. - docs/operations.md: '미해결' 절을 원인·수치·조치·확인법으로 교체. - fingerprint.py: JS 위장이 이 스택에서 불가능하다는 실측 기록 유지(재시도 방지). 테스트 8건 추가(리눅스에서만 보정·실제 버전 유지·조회 실패 폴백·네이버만 opt-in), 전체 270 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
7.0 KiB
Python
116 lines
7.0 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 config.server_configs import worker_config
|
|
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"}
|
|
# ── 컨테이너 차단 대응(2026-08-06 운영) ────────────────────────────────────
|
|
# 같은 컨테이너에서 쿠팡은 60건 정상인데 네이버만 매 IP 첫 요청부터 wtm_captcha 다.
|
|
# 위 리소스 차단 주석의 결론('요청 가로채기 자체가 탐지 신호')을 계측 CDP 에도 적용해 본다 —
|
|
# Network.enable 도 같은 계열이라 매 검색마다 CDP 를 여는 셈이었다.
|
|
# 값은 [WorkerConfig].naver_net_meter 에서 온다(config 는 마운트라 **재빌드 없이** A/B 가능).
|
|
# 쿠팡은 잘 통과하므로 건드리지 않는다 — 멀쩡한 걸 바꿔 깨뜨리는 게 가장 나쁘다.
|
|
net_meter = worker_config.naver_net_meter
|
|
# 리눅스 UA 는 네이버가 HTTP 405 로 거부한다 — JS 가 돌기도 전에 막히므로 지문 문제가 아니다.
|
|
# 실측(2026-08-06, KR 프록시·IP 3개씩): Linux UA 0/3 통과 · Mac UA 3/3 통과(200·6건·1.0MB).
|
|
# 맥/윈도우에서는 자동으로 적용되지 않는다(이미 통과하는 환경을 위장하면 오히려 튄다).
|
|
mac_ua_on_linux = True
|
|
|
|
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)
|