diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker index 7d5c553..82f9a89 100644 --- a/lps/Dockerfile.worker +++ b/lps/Dockerfile.worker @@ -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 diff --git a/lps/config/config_models.py b/lps/config/config_models.py index 2a54a72..0976a16 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -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): diff --git a/lps/diag_naver.py b/lps/diag_naver.py index 338aa6e..8164cc4 100644 --- a/lps/diag_naver.py +++ b/lps/diag_naver.py @@ -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 요청 ──") diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 5021dfb..c77c87d 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -219,34 +219,40 @@ LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라 | **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 | | **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 | -### 컨테이너 크롤 차단 (2026-08-05 실측 · 미해결) +### 네이버 컨테이너 차단 (2026-08-06 · **해결**) -**증상**: 같은 코드·같은 공인 IP인데 호스트에서는 되고 컨테이너에서만 막힌다. +**증상**: 같은 코드·같은 IP 인데 호스트는 되고 컨테이너에서만 네이버가 막혔다(HTTP 405 + wtm_captcha). +쿠팡은 같은 컨테이너에서 정상이었다. -| | 호스트(macOS Chrome) | 컨테이너(Linux Chrome + Xvfb) | -|---|---|---| -| 네이버 msearch | 200 · 40건 | **405 + wtm_captcha** | -| 쿠팡 | 200 · 60건 | **403 Akamai(엣지)** | +**원인**: 컨테이너가 아니라 **User-Agent 의 플랫폼 토큰**. 네이버는 리눅스 데스크톱 Chrome 을 거부한다. -배제한 원인: 공인 IP(동일)·TLS 지문(JA4·H2 해시 동일)·HTTP 헤더(HTTPS 에서 구조·순서 완전 동일, -차이는 `Accept-Language`/`Sec-Ch-Ua-Platform` 두 값뿐)·로케일(ko-KR 로 맞춰도 동일)·WebGL(SwiftShader 로 -살려도 동일)·UA/플랫폼 스푸핑·리소스 라우팅. +| UA | 결과 | +|---|---| +| `X11; Linux x86_64` | **0/3 통과** — 전부 HTTP 405 + wtm_captcha (~50KB) | +| `Macintosh; Intel Mac OS X` | **3/3 통과** — 전부 HTTP 200 · 6건 · ~1.0MB | +| 안드로이드·아이폰 모바일 | 0/2 — HTTP 418 `비정상적인 접근`(2.6KB, 회전 무효 하드차단) | -**주의**: 실측 환경이 Apple Silicon 맥이라 컨테이너가 **amd64 를 Rosetta 로 에뮬레이션**한다. -실제 x86 리눅스 서버에서는 다를 수 있고, 2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다. -그러니 "컨테이너는 안 된다"가 아니라 **"이 맥의 컨테이너에서는 안 된다"** 로 읽어야 한다. +같은 KR 프록시로 IP 3개씩 실측. 프록시 없이(동일 IP) 호스트↔컨테이너 비교에서도 결과가 같아 +IP·게이트웨이 요인은 배제됐다. 맥에서 잘 되던 이유도 이걸로 설명된다. -**2026-08-05 이후 피해 범위**: 서킷브레이커가 붙어서, 이 상황이 와도 **프록시 풀은 안 마른다**. -예전엔 웜업만으로 워커당 6포트, 잡 1건당 6포트를 30분 쿨다운에 묶어 잡 16건이면 100포트가 -고갈됐다(실측). 지금은 판정 근거로 2개를 쓴 뒤 소각이 멈추고 `fatal_block` 알림이 곧바로 뜬다. -즉 **"조용히 풀만 태우다 멈추는" 실패가 "즉시 알리고 멈추는" 실패로 바뀌었다** — 원인 자체는 -아직 미해결이므로 아래 순서로 확인한다. +**405 라는 점이 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 WebGL·폰트·plugins +같은 JS 지문은 원인이 될 수 없었다(실측으로도 확인: `--enable-unsafe-swiftshader` 로 WebGL 을 +살려줘도 405 그대로). -**배포 시 확인 순서** -1. `docker logs lps-worker | grep -E "warmup|환경 차단"` — 기동 직후 소스별 통과 여부가 찍힌다 -2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지) -3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다 - (compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다) +**조치**: `NaverShopAdapter.mac_ua_on_linux = True` — 리눅스에서만 UA 의 플랫폼 토큰을 맥으로 바꾼다. +Chrome 버전은 실제 값을 읽어 유지한다(하드코딩하면 업데이트 시 불일치가 새 신호가 된다). +쿠팡은 잘 통과하므로 UA 를 건드리지 않는다. 자세한 근거는 `services/search/user_agent.py`. + +**확인** +```bash +docker logs lps-worker | grep -E "warmup:naver|환경 차단" # 통과하면 [warmup:naver] 통과·쿠키 확보 +docker cp lps/diag_naver.py lps-worker:/app/ && \ + docker exec -it lps-worker python /app/diag_naver.py # 지문·통과 여부 한 번에 +``` + +> 참고: JS 로 지문을 덮는 방법(WebGL 스푸핑 등)은 이 스택에서 **동작하지 않는다** — +> patchright 가 `add_init_script`(CDP 주입)을 무력화하고, MV3 확장도 값이 바뀌지 않았다. +> 배경은 `services/search/fingerprint.py` docstring. ## 7. Docker 배포 diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index ab0b538..fb47528 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -23,6 +23,7 @@ from common.logger import LOG from config.server_configs import decodo_config, worker_config from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.rate_limiter import RateLimiter +from services.search.user_agent import resolve_ua # 대역폭 절감 기본 차단 집합(쿠팡): 이미지/미디어/폰트/CSS. 쿠팡은 CSS 없이도 파싱·Akamai 통과 OK. # 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override). @@ -91,6 +92,14 @@ 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 + # 리눅스에서 UA 플랫폼 토큰을 맥으로 바꿀지(services/search/user_agent.py). + # 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다(실측: Linux UA 0/3, Mac UA 3/3). + # **기본 꺼짐** — 잘 통과하는 소스(쿠팡)의 UA 를 굳이 건드리지 않는다. 필요한 어댑터만 켠다. + mac_ua_on_linux: bool = False 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, @@ -224,6 +233,10 @@ class BrowserSearchAdapter(SearchAdapter): self._pw = await async_playwright().start() kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) kwargs.update(self.context_options) # 사이트별 컨텍스트 옵션(예: 네이버 locale/timezone) + if self.mac_ua_on_linux and "user_agent" not in kwargs: + ua = resolve_ua(_CHROME_EXECUTABLE) # 리눅스가 아니면 None → Chrome 기본 UA 유지 + if ua: + kwargs["user_agent"] = ua if _CHROME_EXECUTABLE: kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium # 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지. @@ -314,8 +327,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) diff --git a/lps/services/search/fingerprint.py b/lps/services/search/fingerprint.py new file mode 100644 index 0000000..775dbb2 --- /dev/null +++ b/lps/services/search/fingerprint.py @@ -0,0 +1,94 @@ +"""브라우저 지문 판정 — '네이버 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 ← '가상화된 자동화 환경' 신호 + +## 결론 (2026-08-06 컨테이너 재현으로 규명 완료) + +**원인은 JS 지문이 아니라 UA 의 플랫폼 토큰이었다.** 차단이 HTTP 405 로 온다는 게 열쇠였다 — +JS 가 돌기도 전에 거부당하므로 WebGL·폰트·plugins 는 애초에 볼 수도 없는 값이다. +실측: WebGL 을 SwiftShader 로 살려줘도 405 그대로 / UA 만 맥으로 바꾸면 200·통과. +자세한 근거와 수치는 services/search/user_agent.py 를 본다. + +아래 위장 관련 기록은 그 과정에서 확인한 사실이라 남긴다(다시 시도하지 않도록). + +## ⚠️ JS 위장(스푸핑)은 이 스택에서 불가능하다 — 시도하기 전에 읽을 것 + +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", "")) + # '없음'은 소프트웨어 렌더링보다 더 튄다 — 실제 브라우저에서 WebGL 이 아예 없는 경우는 드물다. + # (초기 판정에서 이 경우를 OK 로 흘려보내 진단이 침묵했다 — 2026-08-06 컨테이너 재현 중 발견) + missing = ("없음" in r) or ("ERR" in r) or not r + out.append(("WebGL 렌더러", not (missing or is_software_renderer(r)), r or "(빈 값)", + "GPU 없는 서버의 신호. 실기기는 Apple/Intel/NVIDIA 등 하드웨어 이름이 나온다. " + "--enable-unsafe-swiftshader 로 존재하게 만들 수는 있다. 다만 네이버 405 차단의 " + "원인은 아니었다(실측: WebGL 을 살려도 405 그대로 — 그 차단은 JS 이전 HTTP 계층이다)")) + + # 네이버 405 차단의 진짜 원인. JS 가 돌기 전에 막히므로 위의 JS 지문들과는 층이 다르다. + ua = str(fp.get("userAgent", "")) + linux_ua = "Linux" in ua or "X11" in ua + out.append(("UA 플랫폼", not linux_ua, ua[:70], + "네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다" + "(실측 2026-08-06: Linux UA 0/3 통과 · Mac UA 3/3 통과). " + "NaverShopAdapter.mac_ua_on_linux 로 보정한다")) + + 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 diff --git a/lps/services/search/naver_shop/adapter.py b/lps/services/search/naver_shop/adapter.py index d147d6c..40bea4d 100644 --- a/lps/services/search/naver_shop/adapter.py +++ b/lps/services/search/naver_shop/adapter.py @@ -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,17 @@ 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 + # 리눅스 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): """첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다. diff --git a/lps/services/search/user_agent.py b/lps/services/search/user_agent.py new file mode 100644 index 0000000..39dbf8f --- /dev/null +++ b/lps/services/search/user_agent.py @@ -0,0 +1,85 @@ +"""User-Agent 플랫폼 보정 — 네이버가 **리눅스 데스크톱 Chrome 을 거부**하는 문제 대응. + +## 무엇을 발견했나 (2026-08-06, 컨테이너 재현 실측) + +'컨테이너에서만 네이버가 막힌다'의 진짜 원인은 컨테이너가 아니라 **UA 의 플랫폼 토큰**이었다. +같은 IP·같은 이미지에서 UA 만 바꿔 실측했다: + + KR 프록시 경유, 서로 다른 IP 3개씩 + X11; Linux x86_64 0/3 통과 전부 HTTP 405 + wtm_captcha (~50KB) + Macintosh; Intel Mac 3/3 통과 전부 HTTP 200 · 6건 · ~1.0MB + +프록시 없이(같은 집 IP)도 결과가 같았다 — 호스트 통과 / 컨테이너 차단. 즉 IP·게이트웨이· +컨테이너 자체가 아니라 **리눅스라고 말하는 UA** 가 원인이다. 맥에서 잘 되던 이유도 이걸로 설명된다. + +**405 라는 점이 중요하다.** JS 가 돌기도 전에 HTTP 계층에서 거부당한다 — +그래서 WebGL·폰트·plugins 같은 JS 지문은 이 차단의 원인이 아니다(실측으로 확인: +SwiftShader 를 살려 WebGL 을 만들어줘도 405 그대로였다). + +## 왜 '버전은 그대로, 플랫폼만' 바꾸나 + +UA 를 통째로 하드코딩하면 컨테이너 Chrome 이 업데이트될 때 **UA 버전과 실제 엔진이 어긋나** +그 불일치가 새로운 봇 신호가 된다. 그래서 실행 중인 Chrome 의 실제 버전을 읽어 +플랫폼 토큰만 갈아끼운다. + +모바일 UA 는 쓰지 않는다 — 실측에서 안드로이드·아이폰 UA 는 더 강하게 막혔다 +(HTTP 418 + '비정상적인 접근' 2,641B = 회전 무효 하드차단). +""" + +import re +import subprocess +import sys +from functools import lru_cache + +from common.logger import LOG + +# Chrome 의 reduced UA 형식. 버전은 major 만 쓰고 나머지는 0 으로 고정하는 게 실제 Chrome 동작이다. +_MAC_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36") + +_DEFAULT_MAJOR = "140" # --version 조회 실패 시 폴백(형식만 맞으면 되고, 값은 보수적으로 잡는다) + + +def mac_ua(major: str) -> str: + """맥 데스크톱 Chrome UA. major 는 실행 중인 Chrome 의 실제 메이저 버전.""" + return _MAC_UA.format(major=major) + + +def parse_major(version_output: str) -> str | None: + """`chrome --version` 출력에서 메이저 버전만. 예: 'Google Chrome 150.0.7258.66' → '150'.""" + m = re.search(r"(\d+)\.\d+\.\d+", version_output or "") + return m.group(1) if m else None + + +@lru_cache(maxsize=4) +def chrome_major(executable: str | None) -> str: + """설치된 Chrome 의 메이저 버전(프로세스당 1회만 조회 — lru_cache). + + 실패해도 예외를 내지 않는다. UA 보정은 '있으면 좋은' 보강이지, 이것 때문에 크롤이 멈추면 안 된다. + """ + for cmd in ([executable] if executable else []) + ["google-chrome", "google-chrome-stable", "chromium"]: + try: + out = subprocess.run([cmd, "--version"], capture_output=True, text=True, timeout=10) + major = parse_major(out.stdout or out.stderr) + if major: + return major + except Exception: + continue + LOG.d(f"[ua] Chrome 버전 조회 실패 — 폴백 {_DEFAULT_MAJOR} 사용") + return _DEFAULT_MAJOR + + +def needs_mac_ua(platform: str | None = None) -> bool: + """지금 플랫폼이 UA 보정 대상인지 = 리눅스인지. + + 맥·윈도우에서는 보정하지 않는다 — 이미 통과하는 환경을 굳이 위장하면 UA 와 나머지 지문 + (Sec-CH-UA·폰트 등)이 어긋나 오히려 튄다. 리눅스는 어차피 거부당하므로 바꿀 이유가 분명하다. + """ + return (platform or sys.platform).startswith("linux") + + +def resolve_ua(executable: str | None, platform: str | None = None) -> str | None: + """이 환경에서 적용할 UA. 보정이 필요 없으면 None(=Chrome 기본 UA 사용).""" + if not needs_mac_ua(platform): + return None + return mac_ua(chrome_major(executable)) diff --git a/lps/tests/test_fingerprint.py b/lps/tests/test_fingerprint.py new file mode 100644 index 0000000..12060e3 --- /dev/null +++ b/lps/tests/test_fingerprint.py @@ -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 diff --git a/lps/tests/test_user_agent.py b/lps/tests/test_user_agent.py new file mode 100644 index 0000000..db2b727 --- /dev/null +++ b/lps/tests/test_user_agent.py @@ -0,0 +1,70 @@ +"""UA 플랫폼 보정 테스트 — 네이버가 리눅스 데스크톱 Chrome 을 405 로 거부하는 문제 대응. + +여기서 지키는 계약: + - 리눅스에서만 보정한다(맥·윈도우는 이미 통과 — 굳이 위장하면 나머지 지문과 어긋나 오히려 튄다) + - **버전은 실제 Chrome 값**을 쓴다(하드코딩하면 업데이트 시 불일치가 새 신호가 된다) + - 조회 실패해도 크롤을 막지 않는다 +""" + +from services.search import user_agent as ua_mod +from services.search.user_agent import mac_ua, needs_mac_ua, parse_major, resolve_ua + + +def test_only_linux_gets_the_override(): + """맥·윈도우를 위장하면 UA 와 실제 플랫폼 지문이 어긋나 오히려 봇 신호가 된다.""" + assert needs_mac_ua("linux") is True + assert needs_mac_ua("darwin") is False + assert needs_mac_ua("win32") is False + + +def test_resolve_returns_none_off_linux(): + assert resolve_ua("/opt/google/chrome/chrome", platform="darwin") is None + assert resolve_ua(None, platform="win32") is None + + +def test_resolve_on_linux_produces_a_mac_ua(monkeypatch): + monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "150") + ua = resolve_ua("/opt/google/chrome/chrome", platform="linux") + assert "Macintosh; Intel Mac OS X" in ua + assert "Linux" not in ua and "X11" not in ua + + +def test_ua_keeps_the_real_chrome_major(monkeypatch): + """UA 버전이 실제 엔진과 어긋나면 그 불일치가 새로운 지문이 된다.""" + monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "150") + assert "Chrome/150.0.0.0" in resolve_ua("/x", platform="linux") + monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "151") + assert "Chrome/151.0.0.0" in resolve_ua("/x", platform="linux") + + +def test_ua_shape_matches_real_chrome(): + """형식이 어긋나면 그 자체로 탐지된다 — 실제 Chrome 의 reduced UA 형태를 지킨다.""" + ua = mac_ua("150") + assert ua.startswith("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") + assert "AppleWebKit/537.36 (KHTML, like Gecko)" in ua + assert ua.endswith("Safari/537.36") + + +def test_parse_major_from_chrome_version_output(): + assert parse_major("Google Chrome 150.0.7258.66 ") == "150" + assert parse_major("Chromium 138.0.7204.100") == "138" + assert parse_major("") is None + assert parse_major("no version here") is None + + +def test_version_lookup_failure_still_yields_a_valid_ua(monkeypatch): + """--version 조회가 실패해도 크롤이 멈추면 안 된다 — 형식이 온전한 UA 를 낸다.""" + monkeypatch.setattr(ua_mod.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(OSError("no chrome"))) + ua_mod.chrome_major.cache_clear() + ua = resolve_ua("/nonexistent", platform="linux") + assert "Macintosh" in ua and "Chrome/" in ua + ua_mod.chrome_major.cache_clear() + + +def test_naver_opts_in_and_coupang_does_not(): + """UA 보정은 네이버만 켠다 — 쿠팡은 지금 잘 통과하므로 건드리지 않는다.""" + from services.search.coupang.adapter import CoupangAdapter + from services.search.naver_shop.adapter import NaverShopAdapter + + assert NaverShopAdapter.mac_ua_on_linux is True + assert CoupangAdapter.mac_ua_on_linux is False