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>
This commit is contained in:
민헌 2026-08-06 15:40:14 +09:00
parent 9380760869
commit 8d15fe6bb0
6 changed files with 221 additions and 26 deletions

View File

@ -219,34 +219,40 @@ LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라
| **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 | | **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 |
| **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 | | **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 |
### 컨테이너 크롤 차단 (2026-08-05 실측 · 미해결) ### 네이버 컨테이너 차단 (2026-08-06 · **해결**)
**증상**: 같은 코드·같은 공인 IP인데 호스트에서는 되고 컨테이너에서만 막힌다. **증상**: 같은 코드·같은 IP 인데 호스트는 되고 컨테이너에서만 네이버가 막혔다(HTTP 405 + wtm_captcha).
쿠팡은 같은 컨테이너에서 정상이었다.
| | 호스트(macOS Chrome) | 컨테이너(Linux Chrome + Xvfb) | **원인**: 컨테이너가 아니라 **User-Agent 의 플랫폼 토큰**. 네이버는 리눅스 데스크톱 Chrome 을 거부한다.
|---|---|---|
| 네이버 msearch | 200 · 40건 | **405 + wtm_captcha** |
| 쿠팡 | 200 · 60건 | **403 Akamai(엣지)** |
배제한 원인: 공인 IP(동일)·TLS 지문(JA4·H2 해시 동일)·HTTP 헤더(HTTPS 에서 구조·순서 완전 동일, | UA | 결과 |
차이는 `Accept-Language`/`Sec-Ch-Ua-Platform` 두 값뿐)·로케일(ko-KR 로 맞춰도 동일)·WebGL(SwiftShader 로 |---|---|
살려도 동일)·UA/플랫폼 스푸핑·리소스 라우팅. | `X11; Linux x86_64` | **0/3 통과** — 전부 HTTP 405 + wtm_captcha (~50KB) |
| `Macintosh; Intel Mac OS X` | **3/3 통과** — 전부 HTTP 200 · 6건 · ~1.0MB |
| 안드로이드·아이폰 모바일 | 0/2 — HTTP 418 `비정상적인 접근`(2.6KB, 회전 무효 하드차단) |
**주의**: 실측 환경이 Apple Silicon 맥이라 컨테이너가 **amd64 를 Rosetta 로 에뮬레이션**한다. 같은 KR 프록시로 IP 3개씩 실측. 프록시 없이(동일 IP) 호스트↔컨테이너 비교에서도 결과가 같아
실제 x86 리눅스 서버에서는 다를 수 있고, 2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다. IP·게이트웨이 요인은 배제됐다. 맥에서 잘 되던 이유도 이걸로 설명된다.
그러니 "컨테이너는 안 된다"가 아니라 **"이 맥의 컨테이너에서는 안 된다"** 로 읽어야 한다.
**2026-08-05 이후 피해 범위**: 서킷브레이커가 붙어서, 이 상황이 와도 **프록시 풀은 안 마른다**. **405 라는 점이 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 WebGL·폰트·plugins
예전엔 웜업만으로 워커당 6포트, 잡 1건당 6포트를 30분 쿨다운에 묶어 잡 16건이면 100포트가 같은 JS 지문은 원인이 될 수 없었다(실측으로도 확인: `--enable-unsafe-swiftshader` 로 WebGL 을
고갈됐다(실측). 지금은 판정 근거로 2개를 쓴 뒤 소각이 멈추고 `fatal_block` 알림이 곧바로 뜬다. 살려줘도 405 그대로).
즉 **"조용히 풀만 태우다 멈추는" 실패가 "즉시 알리고 멈추는" 실패로 바뀌었다** — 원인 자체는
아직 미해결이므로 아래 순서로 확인한다.
**배포 시 확인 순서** **조치**: `NaverShopAdapter.mac_ua_on_linux = True` — 리눅스에서만 UA 의 플랫폼 토큰을 맥으로 바꾼다.
1. `docker logs lps-worker | grep -E "warmup|환경 차단"` — 기동 직후 소스별 통과 여부가 찍힌다 Chrome 버전은 실제 값을 읽어 유지한다(하드코딩하면 업데이트 시 불일치가 새 신호가 된다).
2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지) 쿠팡은 잘 통과하므로 UA 를 건드리지 않는다. 자세한 근거는 `services/search/user_agent.py`.
3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다
(compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다) **확인**
```bash
docker logs lps-worker | grep -E "warmup:naver|환경 차단" # 통과하면 [warmup:naver] 통과·쿠키 확보
docker cp lps/diag_naver.py lps-worker:/app/ && \
docker exec -it lps-worker python /app/diag_naver.py # 지문·통과 여부 한 번에
```
> 참고: JS 로 지문을 덮는 방법(WebGL 스푸핑 등)은 이 스택에서 **동작하지 않는다** —
> patchright 가 `add_init_script`(CDP 주입)을 무력화하고, MV3 확장도 값이 바뀌지 않았다.
> 배경은 `services/search/fingerprint.py` docstring.
## 7. Docker 배포 ## 7. Docker 배포

View File

@ -23,6 +23,7 @@ from common.logger import LOG
from config.server_configs import decodo_config, worker_config from config.server_configs import decodo_config, worker_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter from services.search.rate_limiter import RateLimiter
from services.search.user_agent import resolve_ua
# 대역폭 절감 기본 차단 집합(쿠팡): 이미지/미디어/폰트/CSS. 쿠팡은 CSS 없이도 파싱·Akamai 통과 OK. # 대역폭 절감 기본 차단 집합(쿠팡): 이미지/미디어/폰트/CSS. 쿠팡은 CSS 없이도 파싱·Akamai 통과 OK.
# 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override). # 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override).
@ -95,6 +96,10 @@ class BrowserSearchAdapter(SearchAdapter):
# 탐지 신호'라 리소스 차단을 끈 이력이 있는데(naver_shop/adapter.py), 계측 CDP 도 같은 계열이라 # 탐지 신호'라 리소스 차단을 끈 이력이 있는데(naver_shop/adapter.py), 계측 CDP 도 같은 계열이라
# 끌 수 있게 열어둔다. 끄면 대역폭 수치가 DOM 크기 근사로 떨어질 뿐 크롤 동작엔 영향 없다. # 끌 수 있게 열어둔다. 끄면 대역폭 수치가 DOM 크기 근사로 떨어질 뿐 크롤 동작엔 영향 없다.
net_meter: bool = True net_meter: bool = True
# 리눅스에서 UA 플랫폼 토큰을 맥으로 바꿀지(services/search/user_agent.py).
# 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다(실측: Linux UA 0/3, Mac UA 3/3).
# **기본 꺼짐** — 잘 통과하는 소스(쿠팡)의 UA 를 굳이 건드리지 않는다. 필요한 어댑터만 켠다.
mac_ua_on_linux: bool = False
def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None, def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None,
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1, proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1,
@ -228,6 +233,10 @@ class BrowserSearchAdapter(SearchAdapter):
self._pw = await async_playwright().start() self._pw = await async_playwright().start()
kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True)
kwargs.update(self.context_options) # 사이트별 컨텍스트 옵션(예: 네이버 locale/timezone) kwargs.update(self.context_options) # 사이트별 컨텍스트 옵션(예: 네이버 locale/timezone)
if self.mac_ua_on_linux and "user_agent" not in kwargs:
ua = resolve_ua(_CHROME_EXECUTABLE) # 리눅스가 아니면 None → Chrome 기본 UA 유지
if ua:
kwargs["user_agent"] = ua
if _CHROME_EXECUTABLE: if _CHROME_EXECUTABLE:
kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium
# 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지. # 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지.

View File

@ -9,7 +9,16 @@ IP 는 정상이다(kr.decodo.com 한국 IP, 해외 IP 하드차단 마커 아
실기기 ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro) ← 통과 환경 실측 실기기 ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro) ← 통과 환경 실측
컨테이너 SwiftShader / llvmpipe ← '가상화된 자동화 환경' 신호 컨테이너 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 실측): WebGL 렌더러 이름을 바꿔 가리려 했으나, **주입 경로가 둘 다 막혔다**(2026-08-06 실측):
@ -43,9 +52,21 @@ def judge(fp: dict) -> list[tuple[str, bool, str, str]]:
out: list[tuple[str, bool, str, str]] = [] out: list[tuple[str, bool, str, str]] = []
r = str(fp.get("glRenderer", "")) r = str(fp.get("glRenderer", ""))
out.append(("WebGL 렌더러", not is_software_renderer(r), r, # '없음'은 소프트웨어 렌더링보다 더 튄다 — 실제 브라우저에서 WebGL 이 아예 없는 경우는 드물다.
"GPU 없는 서버의 대표 신호. 실기기는 Apple/Intel/NVIDIA 등 하드웨어 이름이 나온다. " # (초기 판정에서 이 경우를 OK 로 흘려보내 진단이 침묵했다 — 2026-08-06 컨테이너 재현 중 발견)
"JS 로는 위장이 불가하므로(모듈 docstring 참고) 실행 환경을 바꿔야 한다")) 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", "")) wd = str(fp.get("webdriver", ""))
out.append(("navigator.webdriver", wd != "true", wd, out.append(("navigator.webdriver", wd != "true", wd,

View File

@ -74,6 +74,10 @@ class NaverShopAdapter(BrowserSearchAdapter):
# 값은 [WorkerConfig].naver_net_meter 에서 온다(config 는 마운트라 **재빌드 없이** A/B 가능). # 값은 [WorkerConfig].naver_net_meter 에서 온다(config 는 마운트라 **재빌드 없이** A/B 가능).
# 쿠팡은 잘 통과하므로 건드리지 않는다 — 멀쩡한 걸 바꿔 깨뜨리는 게 가장 나쁘다. # 쿠팡은 잘 통과하므로 건드리지 않는다 — 멀쩡한 걸 바꿔 깨뜨리는 게 가장 나쁘다.
net_meter = worker_config.naver_net_meter net_meter = worker_config.naver_net_meter
# 리눅스 UA 는 네이버가 HTTP 405 로 거부한다 — JS 가 돌기도 전에 막히므로 지문 문제가 아니다.
# 실측(2026-08-06, KR 프록시·IP 3개씩): Linux UA 0/3 통과 · Mac UA 3/3 통과(200·6건·1.0MB).
# 맥/윈도우에서는 자동으로 적용되지 않는다(이미 통과하는 환경을 위장하면 오히려 튄다).
mac_ua_on_linux = True
async def _wait_ready(self, page): async def _wait_ready(self, page):
"""첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다. """첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다.

View File

@ -0,0 +1,85 @@
"""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))

View File

@ -0,0 +1,70 @@
"""UA 플랫폼 보정 테스트 — 네이버가 리눅스 데스크톱 Chrome 을 405 로 거부하는 문제 대응.
여기서 지키는 계약:
- 리눅스에서만 보정한다(맥·윈도우는 이미 통과 — 굳이 위장하면 나머지 지문과 어긋나 오히려 튄다)
- **버전은 실제 Chrome 값**을 쓴다(하드코딩하면 업데이트 시 불일치가 새 신호가 된다)
- 조회 실패해도 크롤을 막지 않는다
"""
from services.search import user_agent as ua_mod
from services.search.user_agent import mac_ua, needs_mac_ua, parse_major, resolve_ua
def test_only_linux_gets_the_override():
"""맥·윈도우를 위장하면 UA 와 실제 플랫폼 지문이 어긋나 오히려 봇 신호가 된다."""
assert needs_mac_ua("linux") is True
assert needs_mac_ua("darwin") is False
assert needs_mac_ua("win32") is False
def test_resolve_returns_none_off_linux():
assert resolve_ua("/opt/google/chrome/chrome", platform="darwin") is None
assert resolve_ua(None, platform="win32") is None
def test_resolve_on_linux_produces_a_mac_ua(monkeypatch):
monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "150")
ua = resolve_ua("/opt/google/chrome/chrome", platform="linux")
assert "Macintosh; Intel Mac OS X" in ua
assert "Linux" not in ua and "X11" not in ua
def test_ua_keeps_the_real_chrome_major(monkeypatch):
"""UA 버전이 실제 엔진과 어긋나면 그 불일치가 새로운 지문이 된다."""
monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "150")
assert "Chrome/150.0.0.0" in resolve_ua("/x", platform="linux")
monkeypatch.setattr(ua_mod, "chrome_major", lambda _exe: "151")
assert "Chrome/151.0.0.0" in resolve_ua("/x", platform="linux")
def test_ua_shape_matches_real_chrome():
"""형식이 어긋나면 그 자체로 탐지된다 — 실제 Chrome 의 reduced UA 형태를 지킨다."""
ua = mac_ua("150")
assert ua.startswith("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)")
assert "AppleWebKit/537.36 (KHTML, like Gecko)" in ua
assert ua.endswith("Safari/537.36")
def test_parse_major_from_chrome_version_output():
assert parse_major("Google Chrome 150.0.7258.66 ") == "150"
assert parse_major("Chromium 138.0.7204.100") == "138"
assert parse_major("") is None
assert parse_major("no version here") is None
def test_version_lookup_failure_still_yields_a_valid_ua(monkeypatch):
"""--version 조회가 실패해도 크롤이 멈추면 안 된다 — 형식이 온전한 UA 를 낸다."""
monkeypatch.setattr(ua_mod.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(OSError("no chrome")))
ua_mod.chrome_major.cache_clear()
ua = resolve_ua("/nonexistent", platform="linux")
assert "Macintosh" in ua and "Chrome/" in ua
ua_mod.chrome_major.cache_clear()
def test_naver_opts_in_and_coupang_does_not():
"""UA 보정은 네이버만 켠다 — 쿠팡은 지금 잘 통과하므로 건드리지 않는다."""
from services.search.coupang.adapter import CoupangAdapter
from services.search.naver_shop.adapter import NaverShopAdapter
assert NaverShopAdapter.mac_ua_on_linux is True
assert CoupangAdapter.mac_ua_on_linux is False