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

86 lines
4.0 KiB
Python

"""User-Agent 플랫폼 보정 — 네이버가 **리눅스 데스크톱 Chrome 을 거부**하는 문제 대응.
## 무엇을 발견했나 (2026-08-06, 컨테이너 재현 실측)
'컨테이너에서만 네이버가 막힌다'의 진짜 원인은 컨테이너가 아니라 **UA 의 플랫폼 토큰**이었다.
같은 IP·같은 이미지에서 UA 만 바꿔 실측했다:
KR 프록시 경유, 서로 다른 IP 3개씩
X11; Linux x86_64 0/3 통과 전부 HTTP 405 + wtm_captcha (~50KB)
Macintosh; Intel Mac 3/3 통과 전부 HTTP 200 · 6건 · ~1.0MB
프록시 없이(같은 집 IP)도 결과가 같았다 — 호스트 통과 / 컨테이너 차단. 즉 IP·게이트웨이·
컨테이너 자체가 아니라 **리눅스라고 말하는 UA** 가 원인이다. 맥에서 잘 되던 이유도 이걸로 설명된다.
**405 라는 점이 중요하다.** JS 가 돌기도 전에 HTTP 계층에서 거부당한다 —
그래서 WebGL·폰트·plugins 같은 JS 지문은 이 차단의 원인이 아니다(실측으로 확인:
SwiftShader 를 살려 WebGL 을 만들어줘도 405 그대로였다).
## 왜 '버전은 그대로, 플랫폼만' 바꾸나
UA 를 통째로 하드코딩하면 컨테이너 Chrome 이 업데이트될 때 **UA 버전과 실제 엔진이 어긋나**
그 불일치가 새로운 봇 신호가 된다. 그래서 실행 중인 Chrome 의 실제 버전을 읽어
플랫폼 토큰만 갈아끼운다.
모바일 UA 는 쓰지 않는다 — 실측에서 안드로이드·아이폰 UA 는 더 강하게 막혔다
(HTTP 418 + '비정상적인 접근' 2,641B = 회전 무효 하드차단).
"""
import re
import subprocess
import sys
from functools import lru_cache
from common.logger import LOG
# Chrome 의 reduced UA 형식. 버전은 major 만 쓰고 나머지는 0 으로 고정하는 게 실제 Chrome 동작이다.
_MAC_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36")
_DEFAULT_MAJOR = "140" # --version 조회 실패 시 폴백(형식만 맞으면 되고, 값은 보수적으로 잡는다)
def mac_ua(major: str) -> str:
"""맥 데스크톱 Chrome UA. major 는 실행 중인 Chrome 의 실제 메이저 버전."""
return _MAC_UA.format(major=major)
def parse_major(version_output: str) -> str | None:
"""`chrome --version` 출력에서 메이저 버전만. 예: 'Google Chrome 150.0.7258.66''150'."""
m = re.search(r"(\d+)\.\d+\.\d+", version_output or "")
return m.group(1) if m else None
@lru_cache(maxsize=4)
def chrome_major(executable: str | None) -> str:
"""설치된 Chrome 의 메이저 버전(프로세스당 1회만 조회 — lru_cache).
실패해도 예외를 내지 않는다. UA 보정은 '있으면 좋은' 보강이지, 이것 때문에 크롤이 멈추면 안 된다.
"""
for cmd in ([executable] if executable else []) + ["google-chrome", "google-chrome-stable", "chromium"]:
try:
out = subprocess.run([cmd, "--version"], capture_output=True, text=True, timeout=10)
major = parse_major(out.stdout or out.stderr)
if major:
return major
except Exception:
continue
LOG.d(f"[ua] Chrome 버전 조회 실패 — 폴백 {_DEFAULT_MAJOR} 사용")
return _DEFAULT_MAJOR
def needs_mac_ua(platform: str | None = None) -> bool:
"""지금 플랫폼이 UA 보정 대상인지 = 리눅스인지.
맥·윈도우에서는 보정하지 않는다 — 이미 통과하는 환경을 굳이 위장하면 UA 와 나머지 지문
(Sec-CH-UA·폰트 등)이 어긋나 오히려 튄다. 리눅스는 어차피 거부당하므로 바꿀 이유가 분명하다.
"""
return (platform or sys.platform).startswith("linux")
def resolve_ua(executable: str | None, platform: str | None = None) -> str | None:
"""이 환경에서 적용할 UA. 보정이 필요 없으면 None(=Chrome 기본 UA 사용)."""
if not needs_mac_ua(platform):
return None
return mac_ua(chrome_major(executable))