feat(lps): DECODO 포트기반 sticky + 대역폭 최적화(리소스 차단)
대시보드 실측 반영 — Decodo residential 은 포트 기반 sticky(gate:10001..N, 고정 user/pass, 각 포트=sticky 세션). IP 회전 = 포트 순환. - proxy.DecodoProxy: 시간창 기반 포트 선택(창 안 동일 IP, 창 지나면 다음 포트=새 IP). _UNSET sentinel 로 '미지정(env)' vs '명시적 빈값' 구분(테스트 결정성) - coupang/adapter: 이미지/미디어/폰트/CSS 차단(block_resources) — per-GB 대역폭 대폭 절감, Akamai(JS)·상품파싱엔 무영향. 라이브 검증: 차단 ON 에도 Akamai 통과+파싱 정상 - .env.example: DECODO_HOST/USERNAME/PASSWORD/PORT_START/PORT_END/SESSION_MINUTES - tests: 포트범위/시간창 안정성/활성조건 5건 → 전체 44/44 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4de85d43ed
commit
2481c79e3f
@ -14,11 +14,12 @@ NAVER_CLIENT_SECRET=""
|
|||||||
# ── AI (상품 유사도 판정 / 검색어 생성) — OpenAI ──────────────────────
|
# ── AI (상품 유사도 판정 / 검색어 생성) — OpenAI ──────────────────────
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
|
|
||||||
# ── DECODO residential 프록시 (쿠팡 전용, sticky 세션+주기적 회전) ─────
|
# ── DECODO residential 프록시 (쿠팡 전용, 포트 기반 sticky+주기 회전) ──
|
||||||
# DECODO 대시보드의 residential 게이트웨이/자격증명. 4개 다 채워야 활성화(비면 프록시 미사용).
|
# DECODO 대시보드 Residential > Proxy setup 값. HOST/USER/PASS + 포트범위 다 채우면 활성.
|
||||||
# username 은 base 값만(코드가 -session-<id>-sessionduration-<분> 을 자동으로 붙임).
|
# IP 회전 = 포트 순환(각 포트가 sticky 세션). SESSION_MINUTES 는 대시보드 Sticky 지속시간과 맞춘다.
|
||||||
DECODO_HOST="" # 예: gate.decodo.com
|
DECODO_HOST="" # 예: gate.decodo.com
|
||||||
DECODO_PORT="" # 예: 7000
|
DECODO_USERNAME="" # 대시보드 USERNAME (예: sppd6a3ze3)
|
||||||
DECODO_USERNAME=""
|
DECODO_PASSWORD="" # 대시보드 PASSWORD
|
||||||
DECODO_PASSWORD=""
|
DECODO_PORT_START="10001" # 엔드포인트 포트 시작
|
||||||
DECODO_SESSION_MINUTES="10" # 같은 IP 유지 시간(분) → 이 주기로 회전
|
DECODO_PORT_END="10010" # 엔드포인트 포트 끝
|
||||||
|
DECODO_SESSION_MINUTES="10" # 대시보드 Sticky 지속시간(분)과 일치 → 이 주기로 포트/ IP 회전
|
||||||
|
|||||||
@ -22,16 +22,19 @@ from services.search.coupang.selectors import SELECTORS
|
|||||||
|
|
||||||
_SEARCH_URL = "https://www.coupang.com/np/search?q={q}&channel=user&listSize={n}"
|
_SEARCH_URL = "https://www.coupang.com/np/search?q={q}&channel=user&listSize={n}"
|
||||||
_BLOCK_MARKERS = ("sec-if-cpt-container", "Powered and protected", "/akam/")
|
_BLOCK_MARKERS = ("sec-if-cpt-container", "Powered and protected", "/akam/")
|
||||||
|
# 대역폭 절감: 이미지/미디어/폰트/CSS 는 상품 데이터·Akamai(JS) 에 불필요 → 차단(프록시 per-GB 비용↓).
|
||||||
|
_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"}
|
||||||
|
|
||||||
|
|
||||||
class CoupangAdapter(SearchAdapter):
|
class CoupangAdapter(SearchAdapter):
|
||||||
source = "coupang"
|
source = "coupang"
|
||||||
|
|
||||||
def __init__(self, headless: bool = False, user_data_dir: str = "/tmp/lps_coupang_profile", rate_limiter: RateLimiter | None = None, proxy=None):
|
def __init__(self, headless: bool = False, user_data_dir: str = "/tmp/lps_coupang_profile", rate_limiter: RateLimiter | None = None, proxy=None, block_resources: bool = True):
|
||||||
self._headless = headless
|
self._headless = headless
|
||||||
self._user_data_dir = user_data_dir
|
self._user_data_dir = user_data_dir
|
||||||
self._rl = rate_limiter or RateLimiter()
|
self._rl = rate_limiter or RateLimiter()
|
||||||
self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결)
|
self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결)
|
||||||
|
self._block_resources = block_resources
|
||||||
self._pw = None
|
self._pw = None
|
||||||
self._ctx = None
|
self._ctx = None
|
||||||
self._launched_at = 0.0
|
self._launched_at = 0.0
|
||||||
@ -39,6 +42,12 @@ class CoupangAdapter(SearchAdapter):
|
|||||||
self._ok = 0
|
self._ok = 0
|
||||||
self._blocked = 0
|
self._blocked = 0
|
||||||
|
|
||||||
|
async def _route(self, route):
|
||||||
|
if route.request.resource_type in _BLOCKED_RESOURCES:
|
||||||
|
await route.abort()
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
|
||||||
def _recycle_due(self) -> bool:
|
def _recycle_due(self) -> bool:
|
||||||
"""프록시 sticky 세션창이 지났으면 브라우저를 재기동해 새 IP 를 받는다."""
|
"""프록시 sticky 세션창이 지났으면 브라우저를 재기동해 새 IP 를 받는다."""
|
||||||
if not (self._proxy and self._proxy.enabled):
|
if not (self._proxy and self._proxy.enabled):
|
||||||
@ -58,6 +67,8 @@ class CoupangAdapter(SearchAdapter):
|
|||||||
if self._proxy and self._proxy.enabled:
|
if self._proxy and self._proxy.enabled:
|
||||||
kwargs["proxy"] = self._proxy.playwright_proxy()
|
kwargs["proxy"] = self._proxy.playwright_proxy()
|
||||||
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
|
self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs)
|
||||||
|
if self._block_resources:
|
||||||
|
await self._ctx.route("**/*", self._route) # 이미지/미디어/폰트/CSS 차단
|
||||||
self._launched_at = time.monotonic()
|
self._launched_at = time.monotonic()
|
||||||
|
|
||||||
async def _close_ctx(self):
|
async def _close_ctx(self):
|
||||||
|
|||||||
@ -1,47 +1,53 @@
|
|||||||
"""DECODO(구 Smartproxy) residential 프록시 제공자.
|
"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용.
|
||||||
|
|
||||||
쿠팡(Akamai) 전용. 핵심: **sticky 세션 + 주기적 회전**.
|
Decodo residential 은 **포트 기반 sticky** 모델이다:
|
||||||
매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지/차단하므로 금물.
|
gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분)
|
||||||
→ 일정 시간(session_minutes) 같은 IP 유지 후 회전한다.
|
username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.**
|
||||||
|
|
||||||
DECODO 는 username 에 세션 파라미터를 붙여 sticky 를 구현한다:
|
매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물.
|
||||||
<username>-session-<id>-sessionduration-<minutes>
|
→ 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP.
|
||||||
세션 id 를 '시간창(now // window)'으로 만들면 창 안에선 같은 IP, 창이 바뀌면 새 IP 가 된다.
|
자격증명/엔드포인트는 .env(DECODO_*) 에서 로드(시크릿).
|
||||||
자격증명은 .env(DECODO_*) 에서 로드(시크릿).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
_UNSET = object() # 미지정(→env 사용) vs 명시적 빈값("" 등, 그대로 사용) 구분
|
||||||
|
|
||||||
|
|
||||||
|
def _int(v, default=None):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class DecodoProxy:
|
class DecodoProxy:
|
||||||
def __init__(self, host=None, port=None, username=None, password=None, session_minutes=None, sticky=True):
|
def __init__(self, host=_UNSET, username=_UNSET, password=_UNSET, port_start=_UNSET, port_end=_UNSET, session_minutes=_UNSET):
|
||||||
self.host = host or os.environ.get("DECODO_HOST")
|
env = os.environ.get
|
||||||
self.port = port or os.environ.get("DECODO_PORT")
|
self.host = env("DECODO_HOST") if host is _UNSET else host
|
||||||
self.username = username or os.environ.get("DECODO_USERNAME")
|
self.username = env("DECODO_USERNAME") if username is _UNSET else username
|
||||||
self.password = password or os.environ.get("DECODO_PASSWORD")
|
self.password = env("DECODO_PASSWORD") if password is _UNSET else password
|
||||||
self.session_minutes = int(session_minutes or os.environ.get("DECODO_SESSION_MINUTES") or 10)
|
self.port_start = _int(env("DECODO_PORT_START") if port_start is _UNSET else port_start)
|
||||||
self.sticky = sticky
|
self.port_end = _int(env("DECODO_PORT_END") if port_end is _UNSET else port_end)
|
||||||
|
self.session_minutes = _int(env("DECODO_SESSION_MINUTES") if session_minutes is _UNSET else session_minutes, 10)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return all([self.host, self.port, self.username, self.password])
|
return all([self.host, self.username, self.password, self.port_start, self.port_end])
|
||||||
|
|
||||||
def _session_id(self) -> int:
|
def _port(self) -> int:
|
||||||
"""시간창 기반 세션 id — 같은 창에선 동일, 창이 지나면 증가(→새 IP)."""
|
"""시간창 기반 포트 선택 — 창 안에선 동일 포트(동일 sticky IP), 창이 지나면 다음 포트."""
|
||||||
return int(time.time() // (self.session_minutes * 60))
|
n = self.port_end - self.port_start + 1
|
||||||
|
bucket = int(time.time() // (self.session_minutes * 60))
|
||||||
def _session_username(self) -> str:
|
return self.port_start + (bucket % n)
|
||||||
if not self.sticky:
|
|
||||||
return self.username
|
|
||||||
return f"{self.username}-session-{self._session_id()}-sessionduration-{self.session_minutes}"
|
|
||||||
|
|
||||||
def playwright_proxy(self) -> dict | None:
|
def playwright_proxy(self) -> dict | None:
|
||||||
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
|
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"server": f"http://{self.host}:{self.port}",
|
"server": f"http://{self.host}:{self._port()}",
|
||||||
"username": self._session_username(),
|
"username": self.username,
|
||||||
"password": self.password,
|
"password": self.password,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,40 @@
|
|||||||
"""DECODO 프록시 제공자 테스트 (순수, 네트워크 불필요)."""
|
"""DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요)."""
|
||||||
|
|
||||||
from services.search.proxy import DecodoProxy
|
from services.search.proxy import DecodoProxy
|
||||||
|
|
||||||
|
|
||||||
def _p(**kw):
|
def _p(**kw):
|
||||||
base = dict(host="gate.decodo.com", port="7000", username="user1", password="pw", session_minutes=10)
|
base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10)
|
||||||
base.update(kw)
|
base.update(kw)
|
||||||
return DecodoProxy(**base)
|
return DecodoProxy(**base)
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_when_credentials_missing():
|
def test_disabled_when_credentials_missing():
|
||||||
assert DecodoProxy(host="", port="", username="", password="").enabled is False
|
assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).enabled is False
|
||||||
assert DecodoProxy(host="h", port="7000", username="", password="pw").enabled is False
|
assert _p(password="").enabled is False
|
||||||
assert _p().playwright_proxy() is not None # 다 채우면 활성
|
assert _p(port_end=None).enabled is False
|
||||||
assert DecodoProxy(host="", port="", username="", password="").playwright_proxy() is None
|
assert _p().enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_playwright_proxy_shape_and_sticky_username():
|
def test_playwright_proxy_shape():
|
||||||
cfg = _p().playwright_proxy()
|
cfg = _p().playwright_proxy()
|
||||||
assert cfg["server"] == "http://gate.decodo.com:7000"
|
assert cfg["username"] == "user1" and cfg["password"] == "pw" # 고정 자격증명
|
||||||
assert cfg["password"] == "pw"
|
assert cfg["server"].startswith("http://gate.decodo.com:")
|
||||||
# sticky: base username + 세션/유지시간 파라미터
|
port = int(cfg["server"].rsplit(":", 1)[1])
|
||||||
assert cfg["username"].startswith("user1-session-")
|
assert 10001 <= port <= 10010 # 포트 범위 안
|
||||||
assert cfg["username"].endswith("-sessionduration-10")
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_sticky_uses_plain_username():
|
def test_disabled_returns_none():
|
||||||
cfg = _p(sticky=False).playwright_proxy()
|
assert DecodoProxy(host="", username="", password="", port_start=None, port_end=None).playwright_proxy() is None
|
||||||
assert cfg["username"] == "user1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_id_rotates_by_time_window():
|
def test_port_selected_within_range_and_stable_in_window():
|
||||||
p = _p(session_minutes=10)
|
p = _p()
|
||||||
sid = p._session_id()
|
port = p._port()
|
||||||
assert isinstance(sid, int)
|
assert 10001 <= port <= 10010
|
||||||
# 같은 창 안에서는 동일해야 함(연속 호출)
|
assert p._port() == port # 같은 시간창에서는 동일 포트(동일 sticky IP)
|
||||||
assert p._session_id() == sid
|
|
||||||
# 창 크기가 다르면(1분) 버킷 값도 달라짐(더 큰 수) — 시간창 분해 검증
|
|
||||||
assert _p(session_minutes=1)._session_id() > sid
|
def test_single_port_range():
|
||||||
|
p = _p(port_start=10001, port_end=10001)
|
||||||
|
assert p._port() == 10001 # 포트 1개면 항상 그 포트
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user