"""브라우저 지문 판정 — '네이버 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