"""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))