o2o-negosium-original/lps/services/search/fingerprint.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

74 lines
4.2 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 ← '가상화된 자동화 환경' 신호
## ⚠️ 위장(스푸핑)은 이 스택에서 불가능하다 — 시도하기 전에 읽을 것
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", ""))
out.append(("WebGL 렌더러", not is_software_renderer(r), r,
"GPU 없는 서버의 대표 신호. 실기기는 Apple/Intel/NVIDIA 등 하드웨어 이름이 나온다. "
"JS 로는 위장이 불가하므로(모듈 docstring 참고) 실행 환경을 바꿔야 한다"))
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