diff --git a/lps/diag_naver.py b/lps/diag_naver.py new file mode 100644 index 0000000..338aa6e --- /dev/null +++ b/lps/diag_naver.py @@ -0,0 +1,191 @@ +"""네이버 WTM 차단 진단 — 컨테이너 안에서 '네이버가 보는 우리 브라우저'를 그대로 찍어낸다. + +배경(2026-08-06 운영 실측): 같은 컨테이너에서 **쿠팡은 60건 정상인데 네이버만** 매 IP 첫 요청부터 +wtm_captcha(43~57KB)로 막힌다. kr_host 는 정상(traceback 에 kr.decodo.com 확인, 마커도 해외 IP +하드차단이 아님). 즉 IP·게이트웨이 문제가 아니라 **브라우저 지문** 쪽이 남은 후보다. + +추측으로 고치지 않기 위해, 고치기 전에 먼저 '무엇이 다른가'를 눈으로 본다. + +실행(재빌드 불필요): + docker cp lps/diag_naver.py lps-worker:/app/diag_naver.py + docker exec -it lps-worker python /app/diag_naver.py + +옵션: + --no-cdp 바이트 계측용 CDP(Network.enable)를 붙이지 않고 시도 — 이 한 줄이 원인인지 A/B + --no-proxy 프록시 없이(서버 공인 IP) 시도 — IP 요인과 지문 요인 분리 + --keep-html 차단 페이지 HTML 을 /tmp 에 저장(내용 확인용) +""" + +import argparse +import asyncio +import os +import sys + +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.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 +from services.search.proxy import DecodoProxy + +QUERY = "생수" +URL = "https://msearch.shopping.naver.com/search/all?query=%EC%83%9D%EC%88%98" + +# 네이버 WTM 이 볼 수 있는 값들. 봇으로 몰릴 만한 항목엔 판정을 붙여 보여준다. +FINGERPRINT_JS = """ +() => { + const gl = (() => { + try { + const c = document.createElement('canvas'); + const g = c.getContext('webgl') || c.getContext('experimental-webgl'); + if (!g) return {vendor: '(webgl 없음)', renderer: '(webgl 없음)'}; + const dbg = g.getExtension('WEBGL_debug_renderer_info'); + return { + vendor: dbg ? g.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : g.getParameter(g.VENDOR), + renderer: dbg ? g.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER), + }; + } catch (e) { return {vendor: 'ERR', renderer: String(e)}; } + })(); + const fonts = ['Nanum Gothic', 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Dotum', 'Gulim']; + return { + userAgent: navigator.userAgent, + languages: (navigator.languages || []).join(','), + language: navigator.language, + platform: navigator.platform, + webdriver: String(navigator.webdriver), + hardwareConcurrency: navigator.hardwareConcurrency, + deviceMemory: navigator.deviceMemory, + maxTouchPoints: navigator.maxTouchPoints, + plugins: navigator.plugins.length, + mimeTypes: navigator.mimeTypes.length, + hasChromeObj: String(!!window.chrome), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + screen: `${screen.width}x${screen.height}`, + avail: `${screen.availWidth}x${screen.availHeight}`, + colorDepth: screen.colorDepth, + dpr: window.devicePixelRatio, + inner: `${window.innerWidth}x${window.innerHeight}`, + outer: `${window.outerWidth}x${window.outerHeight}`, + glVendor: gl.vendor, + glRenderer: gl.renderer, + korFonts: fonts.filter(f => document.fonts.check(`12px "${f}"`)).join(', ') || '(없음)', + }; +} +""" + +# 자동화·가상화로 읽히기 쉬운 값 — 판정 기준과 이유 +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: + proxy = DecodoProxy(host=decodo_config.kr_host or None) # 장부 미사용(1회성 진단) + if not proxy.enabled: + print("⚠️ DECODO 설정이 비어 프록시 없이 진행합니다") + proxy = None + + print("=" * 72) + print(f"네이버 WTM 진단 — 프록시 {'ON ' + str(proxy.host) + ':' + str(proxy.current_port) if proxy else 'OFF(서버 공인 IP)'}" + f" · CDP 계측 {'ON' if use_cdp else 'OFF'}") + print("=" * 72) + + kwargs = dict(user_data_dir="/tmp/diag_naver_profile", headless=False, no_viewport=True) + kwargs.update(NaverShopAdapter.context_options) # 실물과 동일: locale/timezone + exe = worker_config.chrome_executable or None + if exe: + kwargs["executable_path"] = exe + kwargs["args"] = ["--no-sandbox", "--disable-dev-shm-usage"] + else: + kwargs["channel"] = worker_config.chrome_channel + if proxy: + kwargs["proxy"] = proxy.playwright_proxy() + + print(f"[launch] executable={exe or '(channel=' + worker_config.chrome_channel + ')'}" + f" DISPLAY={os.environ.get('DISPLAY', '(없음)')}") + + pw = await async_playwright().start() + ctx = await pw.chromium.launch_persistent_context(**kwargs) + try: + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + + if use_cdp: # 실물 _ensure_net_meter 와 동일 — 이게 탐지 신호인지 A/B 하려고 옵션화 + try: + cdp = await ctx.new_cdp_session(page) + await cdp.send("Network.enable") + print("[cdp] Network.enable 부착됨 (실물과 동일)") + except Exception as ex: + print(f"[cdp] 부착 실패(무시): {type(ex).__name__}") + + # ── 1) 지문 ── + await page.goto("about:blank") + fp = await page.evaluate(FINGERPRINT_JS) + print("\n── 네이버가 보는 우리 브라우저 ──") + for k, v in fp.items(): + 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}") + + # ── 2) 실제 요청 ── + print(f"\n── 네이버 msearch 요청 ──") + resp = await page.goto(URL, wait_until="domcontentloaded", timeout=40000) + print(f" HTTP {resp.status if resp else '?'}") + try: + await page.wait_for_selector(SELECTORS.card, timeout=20000) + print(" 카드 셀렉터 등장 ✅") + except Exception: + print(" 카드 셀렉터 20s 내 미등장 ❌") + html = await page.content() + items = parse_search_html(html, source="naver") + marker = detect_block(html, len(items)) + print(f" 파싱 {len(items)}건 · html {len(html):,}B · 차단마커 {marker!r}") + print(f"\n → {'✅ 통과' if items else '❌ 차단'}") + + if keep_html or not items: + path = f"/tmp/diag_naver_{'cdp' if use_cdp else 'nocdp'}_{'proxy' if proxy else 'direct'}.html" + with open(path, "w") as f: + f.write(html) + print(f" HTML 저장: {path} (docker cp lps-worker:{path} . 로 꺼내볼 수 있습니다)") + finally: + await ctx.close() + await pw.stop() + + +def main(): + ap = argparse.ArgumentParser(description="네이버 WTM 차단 진단(컨테이너 안에서 실행)") + ap.add_argument("--no-cdp", action="store_true", help="바이트 계측 CDP 를 붙이지 않고 시도") + ap.add_argument("--no-proxy", action="store_true", help="프록시 없이(서버 공인 IP) 시도") + ap.add_argument("--keep-html", action="store_true", help="통과해도 HTML 저장") + a = ap.parse_args() + asyncio.run(run(use_cdp=not a.no_cdp, use_proxy=not a.no_proxy, keep_html=a.keep_html)) + + +if __name__ == "__main__": + sys.exit(main())