o2o-negosium-original/lps/tests/test_browser_base.py
민헌 377389f495 fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로
재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물).

**① 요청 예산이 사실상 발화하지 않았다 (핵심)**
유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를
0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴
임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은
포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼
드문드문한 실사용 패턴에서만 깨진다.
파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한
'1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라
IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가).
→ IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
   세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸).

**② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다**
쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로
웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도
9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14).
→ 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정,
   태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계).
   같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요).
   결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0).

**③ 종료가 임대를 반납하지 않았다**
close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용
풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를
위해 그대로 둔다).

**④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다.
sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다.

**⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다**
네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다.
확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은
회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다.

**⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용).
세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지.

테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로
바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:01 +09:00

374 lines
18 KiB
Python

"""브라우저 어댑터 search() 오케스트레이션 테스트.
가장 취약·복잡한 경로(차단 감지→IP 회전 재시도, 프록시 전송오류→회전, 소진→실패)를
mock 페이지로 **결정론적**으로 검증한다(실제 브라우저/네트워크 없이). 파서 자체는
test_coupang_parser / test_openmarket_parser 가 저장 HTML 로 커버.
+ 라이브 스모크: 실제 사이트에 붙어 각 어댑터가 결과를 파싱하는지(셀렉터·안티봇 드리프트 감지).
느리고 IP 의존적이라 기본 skip — LPS_LIVE=1 로 명시 실행(수동/야간용).
"""
import os
import time
import pytest
from services.search.browser_base import BrowserSearchAdapter, ENV_BLOCK_MARKER
from services.search.contract import NormalizedProduct, AdapterError
from services.search.rate_limiter import RateLimiter
# ── mock 하니스: page.goto/content 를 스크립트로 제어 ──────────────────
class _MockPage:
"""steps: 시도별 결과. Exception→goto 가 raise, str→content 가 그 HTML 반환."""
def __init__(self, steps):
self._steps, self._i, self._cur = steps, 0, None
async def goto(self, url, **kw):
self._cur = self._steps[self._i]
self._i += 1
if isinstance(self._cur, BaseException):
raise self._cur
async def content(self):
return self._cur
async def wait_for_selector(self, *a, **k): pass
async def query_selector(self, *a, **k): return None
async def wait_for_timeout(self, *a, **k): pass
async def evaluate(self, *a, **k): return None
class _MockCtx:
def __init__(self, page): self.pages = [page]
async def new_page(self): return self.pages[0]
async def cookies(self, *a): return []
async def new_cdp_session(self, *a): raise RuntimeError("no cdp") # → DOM 바이트 폴백
async def close(self): pass
class _MockProxy:
enabled = True
session_minutes = 10
def __init__(self): self.rotations, self.burned, self.kinds = 0, [], []
# kind 는 회전 종류(budget=선제/block=차단/proxy_error/window) — 실물은 budget 일 때 휴식을 준다
def rotate(self, kind: str = "rotate"): self.rotations += 1; self.kinds.append(kind)
def mark_burned(self, port, cooldown_sec=None): self.burned.append(port)
def playwright_proxy(self): return None
@property
def current_port(self):
# 회전하면 실제로 포트가 바뀐다 — 고정값이면 '같은 IP 를 계속 쓰는' 버그를 테스트가 못 본다.
return 10001 + self.rotations
class _MockAdapter(BrowserSearchAdapter):
source = "mock"
block_markers = ("BOTBLOCK",)
min_result_html = 50 # 이보다 짧은 0건 응답 = short_html 차단
def __init__(self, page, **kw):
self._page = page
super().__init__(rate_limiter=RateLimiter(0, 0), **kw)
async def _ensure_browser(self):
# 재기동 판단(_recycle_if_due)·IP 세션 경계(_begin_ip_session)는 **실물 코드를 그대로 탄다**
# — 브라우저 기동만 흉내낸다. 그래야 이 하니스가 진짜 동작을 검증한다.
if not await self._recycle_if_due():
return
self._ctx = _MockCtx(self._page)
await self._begin_ip_session(self._proxy.current_port if self._proxy else None)
self._launched_at = time.monotonic()
self._force_recycle = False
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
async def _wait_ready(self, page): pass
async def _blocking_now(self): return False
def _search_url(self, q, l): return "http://mock"
def _parse(self, html):
return [NormalizedProduct(source="mock", name="p", price=100)] if "PRODUCT" in html else []
def _ad(steps):
return _MockAdapter(_MockPage(steps), proxy=_MockProxy())
async def test_search_returns_products_no_rotation():
ad = _ad(["<PRODUCT>ok</PRODUCT>"])
r = await ad.search("q", limit=5)
assert len(r) == 1 and ad._proxy.rotations == 0
async def test_block_rotates_ip_then_recovers():
# 시도1: 짧은 HTML(차단) → IP 회전 / 시도2: 상품
ad = _ad(["x", "<PRODUCT>ok</PRODUCT>"])
r = await ad.search("q")
assert len(r) == 1 and ad._proxy.rotations == 1
async def test_proxy_transport_error_rotates_then_recovers():
ad = _ad([RuntimeError("Page.goto: net::ERR_TUNNEL_CONNECTION_FAILED"), "<PRODUCT>ok</PRODUCT>"])
r = await ad.search("q")
assert len(r) == 1 and ad._proxy.rotations == 1
async def test_non_proxy_goto_error_raises_no_rotation():
ad = _ad([RuntimeError("net::ERR_NAME_NOT_RESOLVED")]) # DNS — 프록시 문제 아님
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._proxy.rotations == 0
async def test_persistent_block_exhausts_and_raises_blocked():
# 차단 2회 연속(max_block_retries=1) → 재시도 회전 1회 + 소진 후 '다음 검색용' 회전 1회 → blocked=True 로 실패
ad = _ad(["<html>BOTBLOCK</html>", "<html>BOTBLOCK</html>"])
with pytest.raises(AdapterError) as ei:
await ad.search("q")
assert ei.value.blocked is True and ad._proxy.rotations == 2
assert ad._proxy.burned == [10001, 10002] # 차단마다 **그때 쓰던** 포트를 쿨다운 격리
async def test_unknown_short_html_rotates_but_does_not_burn():
"""'0건인데 페이지가 짧다'는 정황일 뿐 — 진짜 검색결과 없음일 수 있다.
확신 없이 태우면 정상 응답에 IP 를 30분 묶는다. 회전·재시도까지만 한다."""
ad = _ad(["x", "x"])
with pytest.raises(AdapterError) as ei:
await ad.search("q")
assert ei.value.blocked is True, "회복 시도는 해야 하므로 blocked 로 알린다"
assert ad._proxy.rotations == 2, "IP 는 바꿔 재시도한다(비용이 짧고 되돌릴 수 있다)"
assert ad._proxy.burned == [], "확신 없는 판정으로 30분 쿨다운을 걸지 않는다"
async def test_unknown_short_html_does_not_trip_the_env_breaker():
"""무결과 질의가 연달아 나와도 '환경 차단'으로 오판해 소스를 멈추면 안 된다."""
ad = _MockAdapter(_MockPage(["x"] * 8), proxy=_MockProxy(), max_block_retries=1)
for _ in range(3):
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
# ── IP 세션 ≠ 브라우저 수명 (2026-08-05 회귀) ────────────────────────────
# 유휴 정리(close_if_idle)는 브라우저만 닫고 IP 임대는 그대로 둔다. 예전엔 재기동 때마다
# 요청 카운터를 0 으로 되돌려, 검색이 유휴 임계보다 뜸하면 **한 IP 를 무한히 쓰면서 예산이
# 영영 발화하지 않았다**(실측: 6회 검색이 전부 같은 포트·ip_req#1). ip_request_no 도 항상 1 로
# 찍혀 '몇 번째 요청에서 막혔나' 진단까지 무너졌다.
async def _idle_cleanup(ad):
"""run_browser_reaper 가 하는 일 — 유휴 임계를 넘겼다고 보고 브라우저만 닫는다."""
ad._last_used -= 10_000
await ad.close_if_idle(120)
async def test_idle_cleanup_keeps_the_ip_session_counter():
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 3), proxy=_MockProxy())
seen = []
for _ in range(3):
await ad.search("q")
seen.append((ad._current_port, ad._ip_requests))
await _idle_cleanup(ad)
assert seen == [(10001, 1), (10001, 2), (10001, 3)], \
"같은 IP 로 돌아왔으면 요청 수가 이어져야 한다(브라우저 재기동은 IP 교체가 아니다)"
async def test_budget_still_fires_when_idle_cleanup_happens_between_searches():
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 4), proxy=_MockProxy(), ip_request_budget=3)
for _ in range(4):
await ad.search("q")
await _idle_cleanup(ad)
assert ad._proxy.rotations == 1, "예산 3회를 채웠으면 4번째 검색 전에 선제 회전해야 한다"
assert ad._proxy.kinds == ["budget"]
assert (ad._current_port, ad._ip_requests) == (10002, 1), "회전 뒤에는 새 IP 로 카운터가 리셋된다"
async def test_session_is_recorded_once_per_ip_not_per_browser_restart():
events = []
async def on_end(e):
events.append(e)
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 4), proxy=_MockProxy(),
ip_request_budget=3, on_session_end=on_end)
for _ in range(4):
await ad.search("q")
await _idle_cleanup(ad)
assert len(events) == 1, "유휴 정리는 IP 세션을 끝내지 않는다 — 포트가 바뀔 때만 1건"
assert events[0]["requests"] == 3 and events[0]["end_reason"] == "budget"
await ad.close() # 종료 시 진행 중이던 IP 세션도 마감된다
assert [e["end_reason"] for e in events] == ["budget", "shutdown"]
await ad.close() # 종료 경로가 겹쳐 두 번 불려도 같은 세션을 또 기록하지 않는다
assert [e["end_reason"] for e in events] == ["budget", "shutdown"]
async def test_sticky_window_expiry_actually_rotates_the_ip():
"""시간창 만료 = 제공자 sticky 세션도 끝났다 → 같은 포트를 계속 붙잡으면 안 된다.
예전엔 브라우저만 재기동하고 임대를 renew 해 IP 가 그대로였다(로그만 'IP 회전')."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy())
await ad.search("q")
assert (ad._current_port, ad._proxy.rotations) == (10001, 0)
ad._launched_at -= ad._proxy.session_minutes * 60 + 1 # sticky 수명 경과
await ad.search("q")
assert ad._proxy.kinds == ["window"], "시간창 만료는 window 사유로 회전한다"
assert (ad._current_port, ad._ip_requests) == (10002, 1), "새 IP 로 바뀌고 카운터도 새로 센다"
async def test_forced_rotation_does_not_double_rotate_on_window():
"""이미 회전이 예약된 상태(예산·차단)에서 시간창까지 걸려도 회전은 1번이어야 한다."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>"] * 2), proxy=_MockProxy(), ip_request_budget=1)
await ad.search("q") # 예산 1회 → 다음 검색 전에 선제 회전 예약
ad._launched_at -= ad._proxy.session_minutes * 60 + 1
await ad.search("q")
assert ad._proxy.kinds == ["budget"], "예약된 회전이 우선 — window 로 덧회전하지 않는다"
assert ad._proxy.rotations == 1
# ── 환경 차단 서킷브레이커 (2026-08-05) ─────────────────────────────────
# 서로 다른 IP 가 연속으로 첫 요청부터 막히면 IP 문제가 아니다 → 태우기를 멈춘다.
# 없을 때는 잡 1건이 포트 6개를 30분 쿨다운에 묶어, 16건이면 풀 100개가 고갈됐다(실측).
async def test_env_block_trips_and_stops_burning_ports():
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 8), proxy=_MockProxy(), max_block_retries=1)
# 검색 1: 서로 다른 IP 2개가 첫 요청부터 차단(임계 3 미달) → 기존대로 태우고 포기
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._proxy.burned == [10001, 10002] and ad._env_blocked is False
# 검색 2: 3번째 IP 도 첫 요청부터 차단 → 트립. 이 포트는 태우지 않는다
with pytest.raises(AdapterError) as ei:
await ad.search("q")
assert ad._env_blocked is True
assert ei.value.fatal is True, "회전으로 회복 불가 → 호출부가 설정을 의심하도록 fatal"
assert ad._proxy.burned == [10001, 10002], "트립한 IP 는 태우지 않는다"
# 검색 3 이후: 계속 막혀도 포트를 더 태우지 않는다
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._proxy.burned == [10001, 10002]
async def test_env_block_records_marker_every_time_it_blocks():
"""알림은 '최근 1h fatal 마커 수'로 판정한다 — 트립 1회만 기록하면 문제가 그대로인데
1시간 뒤 '해소' 알림이 나간다. 그래서 막힌 검색마다 남긴다."""
events = []
async def on_detect(ev):
events.append(ev)
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 6), proxy=_MockProxy(),
max_block_retries=1, on_detect=on_detect)
for _ in range(3): # 검색1=미트립, 검색2=트립, 검색3=트립 유지
with pytest.raises(AdapterError):
await ad.search("q")
assert [e["marker"] for e in events].count(ENV_BLOCK_MARKER) == 2
async def test_repeated_block_on_one_ip_is_not_an_env_block():
"""같은 IP 가 반복해 막히는 건 그냥 나쁜 IP 다 — 환경 차단으로 오판하면 안 된다."""
class _StickyProxy(_MockProxy):
@property
def current_port(self): return 10001 # 회전해도 같은 포트(=같은 IP)
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 8), proxy=_StickyProxy(), max_block_retries=1)
for _ in range(3):
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._env_blocked is False and ad._fresh_ip_blocks == [10001]
async def test_block_after_several_requests_is_not_an_env_block():
"""뒤쪽 요청(ip_req#2+)에서 막힌 건 '이 IP 를 많이 썼다'는 뜻 — 예산 문제지 환경 문제가 아니다."""
ad = _MockAdapter(_MockPage(["<PRODUCT>ok</PRODUCT>", "<html>BOTBLOCK</html>", "<html>BOTBLOCK</html>"]),
proxy=_MockProxy(), max_block_retries=0)
await ad.search("q") # ip_req#1 성공
with pytest.raises(AdapterError):
await ad.search("q") # ip_req#2 에서 차단
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
async def test_success_resets_the_breaker():
# 검색1 = 차단 2회(재시도 포함), 검색2 = 차단 1회로 트립 → 검색3 에서 회복
ad = _MockAdapter(_MockPage(["<html>BOTBLOCK</html>"] * 3 + ["<PRODUCT>ok</PRODUCT>"]),
proxy=_MockProxy(), max_block_retries=1)
for _ in range(2):
with pytest.raises(AdapterError):
await ad.search("q")
assert ad._env_blocked is True
await ad.search("q") # 환경 회복
assert ad._env_blocked is False and ad._fresh_ip_blocks == []
# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ──────
def _live_adapters():
from services.search.proxy import DecodoProxy
from services.search.coupang.adapter import CoupangAdapter
from services.search.esm.adapter import EsmAdapter
from services.search.st11.adapter import ElevenStAdapter
from services.search.naver_shop.adapter import NaverShopAdapter
p = DecodoProxy()
return [
# 네이버는 오픈API 종료(2026-07-31)로 크롤 전환. headless 는 WTM 에 걸리므로 headful.
("naver", NaverShopAdapter(headless=False)),
("coupang", CoupangAdapter(headless=True, proxy=p)),
("gmarket", EsmAdapter("gmarket", headless=True, proxy=p)),
("auction", EsmAdapter("auction", headless=True, proxy=p)),
("st11", ElevenStAdapter(headless=True, proxy=p)),
]
@pytest.mark.skipif(not os.environ.get("LPS_LIVE"), reason="라이브 스모크 — LPS_LIVE=1 로 실행")
@pytest.mark.parametrize("name", ["naver", "coupang", "gmarket", "auction", "st11"])
async def test_live_smoke(name):
ad = dict(_live_adapters())[name]
try:
ps = await ad.search("생수", limit=5)
assert len(ps) > 0, f"{name}: 0건 — 셀렉터/안티봇 드리프트 의심"
assert all(p.price > 0 and p.name for p in ps)
finally:
await ad.close()
# ── 구조적 차단(회전 무효) ───────────────────────────────────────────────
class _FatalAdapter(_MockAdapter):
"""해외 IP 하드차단처럼 '회전해도 같은 결과'인 마커를 가진 어댑터."""
block_markers = ("BOTBLOCK", "HARDBLOCK")
fatal_block_markers = ("HARDBLOCK",)
async def test_fatal_block_does_not_rotate_or_burn():
"""구조적 차단은 IP 문제가 아니다 — 태우면 원인도 못 고친 채 풀만 말린다
(같은 게이트웨이를 쓰는 다른 소스의 IP 까지 쿨다운에 묶인다)."""
page = _MockPage(["<html>HARDBLOCK</html>"])
proxy = _MockProxy()
ad = _FatalAdapter(page, proxy=proxy, max_block_retries=3)
with pytest.raises(AdapterError) as e:
await ad.search("q")
assert e.value.fatal is True and e.value.blocked is True
assert proxy.burned == [], "구조적 차단은 포트를 태우지 않는다"
assert proxy.rotations == 0, "회전해도 소용없으므로 회전하지 않는다"
async def test_fatal_block_is_recorded_for_alerting():
"""감지 기록은 남겨야 한다 — 알림이 이 마커를 세서 '설정 고치라'고 알린다."""
events = []
async def on_detect(ev):
events.append(ev)
page = _MockPage(["<html>HARDBLOCK</html>"])
ad = _FatalAdapter(page, proxy=_MockProxy(), on_detect=on_detect)
with pytest.raises(AdapterError):
await ad.search("q")
assert len(events) == 1 and events[0]["marker"] == "HARDBLOCK"
async def test_ordinary_block_still_rotates_and_burns():
"""일반 차단(IP 평판)은 기존대로 태우고 회전해 회복을 시도한다 — 두 경로가 갈렸는지 확인."""
page = _MockPage(["<html>BOTBLOCK</html>", "<PRODUCT>ok</PRODUCT>"])
proxy = _MockProxy()
ad = _FatalAdapter(page, proxy=proxy, max_block_retries=1)
ps = await ad.search("q")
assert len(ps) > 0
assert proxy.burned == [10001] and proxy.rotations == 1