o2o-negosium-original/lps/diag_naver.py
민헌 9380760869 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>
2026-08-06 15:22:01 +09:00

172 lines
7.5 KiB
Python

"""네이버 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.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
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(', ') || '(없음)',
};
}
"""
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── 자동화/가상화로 읽힐 만한 항목 ──")
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 요청 ──")
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())