o2o-negosium-original/lps/services/search/fingerprint.py
민헌 8d15fe6bb0 fix(lps): 네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰이었다
컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **UA 가 리눅스라고 말하는 것**이
원인이다. 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다.

실측(같은 이미지·같은 KR 프록시, 서로 다른 IP 3개씩):
  X11; Linux x86_64        0/3 통과   전부 405 + wtm_captcha (~50KB)
  Macintosh; Intel Mac     3/3 통과   전부 200 · 6건 · ~1.0MB
  안드로이드·아이폰 모바일    0/2       418 '비정상적인 접근'(2.6KB, 회전 무효 하드차단)
프록시 없이 같은 집 IP 로도 호스트 통과 / 컨테이너 차단이 재현돼 IP·게이트웨이는 배제됐다.
맥에서 잘 되던 이유도 이걸로 설명된다.

**405 가 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 그동안 의심하던
WebGL·폰트·plugins 는 애초에 원인이 될 수 없었다(확인차 --enable-unsafe-swiftshader 로
WebGL 을 살려봤지만 405 그대로였다).

조치:
- services/search/user_agent.py: 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환. Chrome 버전은
  `--version` 으로 실제 값을 읽어 유지한다 — 하드코딩하면 컨테이너 Chrome 업데이트 시
  UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 조회 실패해도 크롤을 막지 않는다.
- NaverShopAdapter.mac_ua_on_linux = True (쿠팡은 잘 통과하므로 기본 False 그대로 — 멀쩡한 걸
  건드리지 않는다). 맥/윈도우에서는 자동 미적용.
- 실제 어댑터로 컨테이너 검증: '생수' 40건, '스페셜티 원두 1kg' 40건 통과.

부수:
- fingerprint.judge: WebGL 이 **아예 없는** 경우를 OK 로 흘려보내던 판정 버그 수정(컨테이너
  재현 중 발견 — 소프트웨어 렌더링보다 더 튀는 값인데 침묵했다). UA 플랫폼 항목 추가.
- docs/operations.md: '미해결' 절을 원인·수치·조치·확인법으로 교체.
- fingerprint.py: JS 위장이 이 스택에서 불가능하다는 실측 기록 유지(재시도 방지).

테스트 8건 추가(리눅스에서만 보정·실제 버전 유지·조회 실패 폴백·네이버만 opt-in), 전체 270 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:40:14 +09:00

95 lines
5.8 KiB
Python

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