fix(lps): 네이버 컨테이너 차단 — 계측 CDP 토글 + 지문 판정 + 폰트 보강

운영 실측으로 범위가 좁혀졌다: 같은 컨테이너에서 **쿠팡은 60건 정상, 네이버만** 매 IP
첫 요청부터 wtm_captcha. kr_host 도 정상이다(ensure_ports 에 kr.decodo.com, 마커가 해외 IP
하드차단 '비정상적인 접근'(2.6KB)이 아니라 wtm_captcha(43~57KB)). IP·게이트웨이가 아니라
브라우저 지문이 남은 후보다.

**먼저 확인한 것 — 지문 위장은 이 스택에서 불가능하다(중요)**
WebGL 렌더러(컨테이너=SwiftShader)를 실기기 이름으로 덮으려 했으나 주입 경로가 둘 다 막혔다:
  context.add_init_script()  무반응. 단순 마커(window.__M__) 주입조차 undefined 다.
                             patchright 가 무력화한다 — 이 API 는 CDP
                             addScriptToEvaluateOnNewDocument 로 구현되고 그 흔적이 탐지
                             신호라, 걷어내는 게 patchright 의 존재 이유다.
  확장(MV3, world:MAIN, document_start)  로드는 되지만 값이 바뀌지 않았다.
→ 동작하지 않는 코드를 남기지 않는다. 스푸핑은 넣지 않고, 대신 **판정**만 한다.
   재시도 방지를 위해 이 실측을 fingerprint.py docstring 에 남겼다.

**넣은 것**
- services/search/fingerprint.py: 지문 판정(항목·정상여부·관측값·왜 문제인가). 진단이 곧
  '무엇을 고칠까'로 이어지도록 이유를 함께 낸다.
- net_meter 토글: 네이버에 계측용 CDP(Network.enable)를 붙이지 않는다(기본 off).
  네이버는 '요청 가로채기 자체가 탐지 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는
  그대로 붙고 있었다 — 같은 계열이다. [WorkerConfig].naver_net_meter 로 재빌드 없이 A/B
  (config 는 마운트). 끄면 대역폭이 DOM 크기 근사로 떨어질 뿐 크롤엔 영향 없다.
  쿠팡은 잘 통과하므로 기본값을 바꾸지 않는다.
- Dockerfile.worker: fonts-noto-cjk·fonts-liberation 추가(나눔 하나뿐이면 폰트 지문이 부자연스럽다).
- diag_naver.py 가 같은 판정 모듈을 쓰도록 정리.

통과 환경 기준값(맥 실측): 의심 항목 0개 · HTTP 200 · 6건 · 1.26MB.
테스트 6건 추가(통과 지문을 문제로 몰지 않는지·컨테이너 지문을 놓치지 않는지), 전체 262 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-08-06 15:22:01 +09:00
parent 3bdff74ceb
commit 9380760869
7 changed files with 175 additions and 31 deletions

View File

@ -9,13 +9,17 @@
FROM python:3.14-slim
# 실제 Chrome(구글 apt 저장소, amd64 전용) + Xvfb + 한글 폰트
# 폰트를 넉넉히 깐다: 네이버는 한국 IP·ko-KR 로케일로 접근하는데 브라우저에 한글 폰트가
# 나눔 하나뿐이면 폰트 열거 지문이 부자연스럽다. noto-cjk(한중일)·liberation(서구권 표준)까지
# 넣어 실사용 데스크톱에 가깝게 맞춘다(이미지 ~100MB 증가, 크롤 성공률과 맞바꿀 가치가 있다).
RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg \
&& wget -qO- https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y --no-install-recommends \
google-chrome-stable xvfb xauth fonts-nanum ca-certificates \
google-chrome-stable xvfb xauth ca-certificates \
fonts-nanum fonts-noto-cjk fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View File

@ -113,6 +113,13 @@ class WorkerConfig(ConfigModel):
chrome_channel: str = "chrome" # 로컬: 실제 Chrome 채널
chrome_executable: str = "" # 컨테이너: 시스템 chromium 경로(설정 시 channel 무시, --no-sandbox 적용)
heartbeat_file: str = "/tmp/lps_worker_heartbeat" # 하트비트 파일(Docker HEALTHCHECK 신선도 확인)
# ── 네이버 WTM 컨테이너 차단 대응(2026-08-06) ──────────────────────────────
# 같은 컨테이너에서 쿠팡은 정상인데 네이버만 매 IP 첫 요청부터 wtm_captcha 로 막힌다.
# 네이버에 바이트 계측 CDP(Network.enable)를 붙일지 — 네이버는 '요청 가로채기 자체가 탐지
# 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는 그대로 붙고 있었다. 끄면 대역폭 수치가
# DOM 크기 근사로 떨어질 뿐 크롤 동작엔 영향이 없다.
# config 는 마운트라 **재빌드 없이** 이 값만 바꿔 재시작하면 A/B 가 된다.
naver_net_meter: bool = False
class AlertConfig(ConfigModel):

View File

@ -26,6 +26,7 @@ os.environ.setdefault("APP_ENV", "local")
from patchright.async_api import async_playwright
from config.server_configs import decodo_config, worker_config
from services.search.fingerprint import judge
from services.search.naver_shop.adapter import NaverShopAdapter, detect_block
from services.search.naver_shop.parser import parse_search_html
from services.search.naver_shop.selectors import SELECTORS
@ -76,30 +77,6 @@ FINGERPRINT_JS = """
}
"""
# 자동화·가상화로 읽히기 쉬운 값 — 판정 기준과 이유
def verdicts(fp: dict) -> list[tuple[str, str, str]]:
out = []
r = str(fp.get("glRenderer", ""))
bad_gl = any(k in r for k in ("SwiftShader", "llvmpipe", "Mesa", "Software"))
out.append(("WebGL 렌더러", "⚠️ 소프트웨어 렌더링" if bad_gl else "OK",
"GPU 없는 서버의 대표 신호. 실기기는 Intel/AMD/Apple 등 하드웨어 이름이 나온다"))
out.append(("navigator.webdriver", "⚠️ true" if fp.get("webdriver") == "true" else "OK",
"true 면 자동화 브라우저임을 스스로 알린다(patchright 가 보통 감춘다)"))
out.append(("plugins", "⚠️ 0개" if not fp.get("plugins") else "OK",
"실제 Chrome 은 보통 PDF 뷰어 등 1개 이상"))
out.append(("한글 폰트", "⚠️ 없음" if fp.get("korFonts") == "(없음)" else "OK",
"한국 사용자 브라우저에 한글 폰트가 없으면 부자연스럽다"))
lang = str(fp.get("languages", ""))
out.append(("languages", "OK" if lang.startswith("ko") else f"⚠️ {lang}",
"한국 IP 인데 ko 가 아니면 WTM 이 캡차를 띄운다(실측)"))
tz = fp.get("timeZone")
out.append(("timeZone", "OK" if tz == "Asia/Seoul" else f"⚠️ {tz}", "한국 IP 와 맞아야 한다"))
avail, scr = fp.get("avail"), fp.get("screen")
out.append(("screen/avail", "⚠️ 동일(작업표시줄 없음)" if avail == scr else "OK",
"창 관리자 없는 Xvfb 의 특징 — 실기기는 보통 다르다"))
return out
async def run(use_cdp: bool, use_proxy: bool, keep_html: bool):
proxy = None
if use_proxy:
@ -148,10 +125,13 @@ async def run(use_cdp: bool, use_proxy: bool, keep_html: bool):
print(f" {k:<20} {v}")
print("\n── 자동화/가상화로 읽힐 만한 항목 ──")
for name, verdict, why in verdicts(fp):
print(f" {verdict:<24} {name}")
if verdict != "OK":
print(f"{why}")
problems = 0
for name, ok, value, why in judge(fp):
print(f" {'OK ' if ok else '⚠️ '} {name:<22} {value}")
if not ok:
problems += 1
print(f"{why}")
print(f" → 의심 항목 {problems}")
# ── 2) 실제 요청 ──
print(f"\n── 네이버 msearch 요청 ──")

View File

@ -91,6 +91,10 @@ class BrowserSearchAdapter(SearchAdapter):
# launch_persistent_context 에 얹을 사이트별 옵션. 네이버 WTM 은 **한국 IP + en-US 로케일** 조합을
# 봇으로 본다(실측: 같은 IP·같은 브라우저에서 locale 만 ko-KR 로 주면 캡차→정상). 기본은 비움.
context_options: dict = {}
# 바이트 계측용 CDP(Network.enable) 부착 여부. 네이버는 '요청 가로채기(CDP Fetch) 자체가
# 탐지 신호'라 리소스 차단을 끈 이력이 있는데(naver_shop/adapter.py), 계측 CDP 도 같은 계열이라
# 끌 수 있게 열어둔다. 끄면 대역폭 수치가 DOM 크기 근사로 떨어질 뿐 크롤 동작엔 영향 없다.
net_meter: bool = True
def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None,
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1,
@ -314,8 +318,10 @@ class BrowserSearchAdapter(SearchAdapter):
pass
async def _ensure_net_meter(self, page):
"""CDP 네트워크 계측 세션 부착(컨텍스트당 1회). 실패(미지원)하면 DOM 크기로 폴백."""
if self._cdp is not None:
"""CDP 네트워크 계측 세션 부착(컨텍스트당 1회). 실패(미지원)하면 DOM 크기로 폴백.
net_meter=False 붙이지 않는다 대역폭 수치는 DOM 크기 근사로 떨어지지만,
CDP 여는 행위 자체가 탐지 신호일 있는 소스(네이버)에서 있어야 한다."""
if self._cdp is not None or not self.net_meter:
return
try:
self._cdp = await self._ctx.new_cdp_session(page)

View File

@ -0,0 +1,73 @@
"""브라우저 지문 판정 — '네이버 WTM 이 우리를 봇으로 볼 만한 값'을 가려낸다(판정 전용).
## 배경 (2026-08-06 운영 실측)
같은 컨테이너·같은 코드인데 **쿠팡은 60 정상이고 네이버만** IP 요청부터 wtm_captcha .
IP 정상이다(kr.decodo.com 한국 IP, 해외 IP 하드차단 마커 아님) 남은 후보는 브라우저 지문이고,
그중 컨테이너에서 반드시 달라지는 값이 WebGL 렌더러다(GPU 없어 소프트웨어로 떨어진다).
실기기 ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro) 통과 환경 실측
컨테이너 SwiftShader / llvmpipe '가상화된 자동화 환경' 신호
## ⚠️ 위장(스푸핑)은 이 스택에서 불가능하다 — 시도하기 전에 읽을 것
WebGL 렌더러 이름을 바꿔 가리려 했으나, **주입 경로가 막혔다**(2026-08-06 실측):
context.add_init_script() 무반응. patchright 무력화한다 API CDP
Page.addScriptToEvaluateOnNewDocument 구현되는데,
흔적 자체가 탐지 신호라 patchright 걷어내는 존재 이유다.
(단순 마커 window.__M__ 주입조차 undefined 나온다)
확장 프로그램(MV3, world:MAIN, document_start) 로드는 되지만 값이 바뀌지 않았다.
**JS 지문을 덮는 방법은 조합(patchright + 실제 Chrome + persistent context)에서 통하지
않는다.** 다시 시도하기 전에 줄을 먼저 재현해 보길 권한다. 남은 현실적 선택지는
'지문을 바꾸는 것' 아니라 **실행 환경을 바꾸는 **(GPU 있는 호스트/노드, 다른 이미지)이다.
그래서 모듈은 위장을 하지 않는다 **무엇이 문제인지 판정해서 보여주는 **까지만 한다.
"""
# 소프트웨어 렌더링(=GPU 없는 서버)을 드러내는 문자열.
SOFTWARE_RENDERER_MARKERS = ("SwiftShader", "llvmpipe", "Mesa OffScreen", "Software", "Microsoft Basic Render")
def is_software_renderer(renderer: str) -> bool:
"""WebGL 렌더러가 소프트웨어인지 — 실기기라면 GPU 이름(Apple/Intel/NVIDIA 등)이 나온다."""
return any(m in (renderer or "") for m in SOFTWARE_RENDERER_MARKERS)
def judge(fp: dict) -> list[tuple[str, bool, str, str]]:
"""지문 dict → [(항목, 정상 여부, 관측값, 왜 문제인가)].
진단 도구가 그대로 출력한다. '무엇을 고쳐야 하나' 바로 이어지도록 이유를 함께 낸다.
"""
out: list[tuple[str, bool, str, str]] = []
r = str(fp.get("glRenderer", ""))
out.append(("WebGL 렌더러", not is_software_renderer(r), r,
"GPU 없는 서버의 대표 신호. 실기기는 Apple/Intel/NVIDIA 등 하드웨어 이름이 나온다. "
"JS 로는 위장이 불가하므로(모듈 docstring 참고) 실행 환경을 바꿔야 한다"))
wd = str(fp.get("webdriver", ""))
out.append(("navigator.webdriver", wd != "true", wd,
"true 면 자동화 브라우저임을 스스로 알린다(patchright 가 보통 감춘다)"))
plugins = fp.get("plugins") or 0
out.append(("plugins", plugins > 0, str(plugins),
"실제 Chrome 은 보통 PDF 뷰어 등 1개 이상. 0개는 자동화 환경에서 흔하다"))
fonts = str(fp.get("korFonts", ""))
out.append(("한글 폰트", fonts not in ("", "(없음)"), fonts,
"한국 IP·ko-KR 로케일인데 한글 폰트가 없으면 부자연스럽다(이미지에 폰트 추가로 해결 가능)"))
lang = str(fp.get("languages", ""))
out.append(("languages", lang.startswith("ko"), lang,
"한국 IP 인데 ko 가 아니면 WTM 이 캡차를 띄운다(실측)"))
tz = str(fp.get("timeZone", ""))
out.append(("timeZone", tz == "Asia/Seoul", tz, "한국 IP 와 맞아야 한다"))
scr, avail = str(fp.get("screen", "")), str(fp.get("avail", ""))
out.append(("screen vs avail", scr != avail, f"{scr} / {avail}",
"창 관리자 없는 Xvfb 는 두 값이 같다 — 실기기는 작업표시줄 때문에 보통 다르다"))
return out

View File

@ -17,6 +17,7 @@
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
@ -66,6 +67,13 @@ class NaverShopAdapter(BrowserSearchAdapter):
# 한국어 로케일/시간대 필수. 한국 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
async def _wait_ready(self, page):
"""첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다.

View File

@ -0,0 +1,66 @@
"""지문 판정 테스트 — 진단 도구가 '무엇이 문제인지' 정확히 짚는지.
판정이 틀리면 엉뚱한 고치게 된다. 특히 통과 환경( 실측) 문제로 몰거나,
컨테이너의 소프트웨어 렌더링을 놓치면 진단 도구의 의미가 없다.
"""
from services.search.fingerprint import is_software_renderer, judge
# 2026-08-06 맥에서 실측한 '통과하는' 지문 그대로.
PASSING = {
"glRenderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro, Unspecified Version)",
"webdriver": "false",
"plugins": 5,
"korFonts": "Nanum Gothic, Malgun Gothic, Apple SD Gothic Neo",
"languages": "ko-KR",
"timeZone": "Asia/Seoul",
"screen": "2560x1080",
"avail": "2560x1050",
}
# GPU 없는 컨테이너에서 나올 법한 지문.
CONTAINER = {**PASSING,
"glRenderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 16.0.0)), SwiftShader driver)",
"plugins": 0, "korFonts": "(없음)", "screen": "1920x1080", "avail": "1920x1080"}
def _by_name(fp):
return {name: (ok, value) for name, ok, value, _ in judge(fp)}
def test_passing_fingerprint_has_no_complaints():
"""통과 환경을 문제로 몰면 진단이 노이즈가 된다."""
assert all(ok for _, ok, _, _ in judge(PASSING)), _by_name(PASSING)
def test_container_fingerprint_flags_software_renderer():
r = _by_name(CONTAINER)
assert r["WebGL 렌더러"][0] is False, "SwiftShader 를 놓치면 진단의 핵심을 놓친다"
def test_container_fingerprint_flags_the_known_gaps():
r = _by_name(CONTAINER)
assert r["plugins"][0] is False
assert r["한글 폰트"][0] is False
assert r["screen vs avail"][0] is False # 창 관리자 없는 Xvfb
def test_every_finding_explains_why():
"""이유 없는 경고는 다음 행동으로 이어지지 않는다."""
for name, ok, value, why in judge(CONTAINER):
assert why and len(why) > 10, name
def test_software_renderer_detection():
assert is_software_renderer("SwiftShader driver")
assert is_software_renderer("Mesa OffScreen")
assert is_software_renderer("llvmpipe (LLVM 15.0.6, 256 bits)")
assert not is_software_renderer("ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro)")
assert not is_software_renderer("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)")
assert not is_software_renderer("")
def test_non_korean_locale_is_flagged():
"""한국 IP 로 접근하면서 en-US 면 캡차가 뜬다(실측) — 이 조합을 놓치면 안 된다."""
r = _by_name({**PASSING, "languages": "en-US", "timeZone": "America/New_York"})
assert r["languages"][0] is False
assert r["timeZone"][0] is False