운영(실 x86 서버) 실측으로 문제 범위가 크게 좁아졌다:
쿠팡 컨테이너에서 **60건 정상**
네이버 매 IP 첫 요청부터 wtm_captcha(43~57KB)
즉 '컨테이너가 문제'가 아니라 **네이버 WTM 만 이 컨테이너를 걸러낸다**. kr_host 도 정상이다
(ensure_ports 에 kr.decodo.com 이 찍혔고, 마커가 해외 IP 하드차단 '비정상적인 접근'(2.6KB)이
아니라 wtm_captcha 다 = 한국 IP 는 제대로 나가고 있다). IP·게이트웨이가 아니라 지문 쪽이 남았다.
서버는 Docker 전용이라 '워커만 호스트 실행' 우회를 쓸 수 없다. 그래서 추측으로 이것저것
고치는 대신, **고치기 전에 무엇이 다른지 눈으로 보는** 도구를 먼저 만든다.
diag_naver.py — 컨테이너 안에서 실물과 동일한 스택(patchright + 실제 Chrome + locale/timezone +
kr 프록시)으로 띄워:
- 네이버 WTM 이 볼 수 있는 값 덤프(UA·languages·webdriver·plugins·screen/avail·devicePixelRatio·
WebGL vendor/renderer·한글 폰트·timeZone …)
- 자동화/가상화로 읽히기 쉬운 항목에 판정과 이유를 붙임
- 실제 msearch 요청 → HTTP·카드 등장 여부·파싱 건수·차단 마커, 실패 시 HTML 저장
--no-cdp 바이트 계측 CDP(Network.enable)를 빼고 A/B — 네이버는 '요청 가로채기 자체가 탐지
신호'라 리소스 차단을 끈 이력이 있는데, 계측용 CDP 는 그대로 붙고 있다
--no-proxy 프록시 없이 시도 — IP 요인과 지문 요인 분리
재빌드 없이 docker cp + docker exec 로 실행한다(사용법은 파일 상단 docstring).
통과 환경 기준값(맥 직결, 참고용):
glRenderer=ANGLE(Apple M3 Pro) · plugins=5 · 한글폰트 6종 · screen 2560x1080/avail 2560x1050
→ HTTP 200 · 파싱 6건 · html 1.26MB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
8.9 KiB
Python
192 lines
8.9 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.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())
|