컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **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>
86 lines
4.0 KiB
Python
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))
|