shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아
가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환.
경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은
로그인 리다이렉트였는데 그 사이 열렸다.
통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측):
- **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이
([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지)
- **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다
(BrowserSearchAdapter.context_options 훅 추가)
- **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 →
'무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009)
파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다:
- 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격)
- 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘)
- 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미)
- **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다
- 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율
source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이
구현(API→크롤) 교체와 무관하게 살아야 한다.
테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed.
113 lines
5.6 KiB
Python
113 lines
5.6 KiB
Python
"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용.
|
|
|
|
Decodo residential 은 **포트 기반 sticky** 모델이다:
|
|
gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분)
|
|
username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.**
|
|
|
|
매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물.
|
|
→ 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP.
|
|
자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿).
|
|
"""
|
|
|
|
import time
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
|
|
from common.logger import LOG
|
|
from config.server_configs import decodo_config
|
|
|
|
|
|
class DecodoProxy:
|
|
def __init__(self, cfg=None, host: str | None = None):
|
|
cfg = cfg if cfg is not None else decodo_config
|
|
# host 를 넘기면 그 게이트웨이를 쓴다 — 국가 타깃(kr.decodo.com)용. 포트/자격증명은 동일.
|
|
self.host = host or cfg.host
|
|
self.username = cfg.username
|
|
self.password = cfg.password
|
|
self.port_start = cfg.port_start
|
|
self.port_end = cfg.port_end
|
|
self.session_minutes = cfg.session_minutes or 10
|
|
self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가
|
|
# 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes)
|
|
# 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분).
|
|
self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800)
|
|
self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic)
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return all([self.host, self.username, self.password, self.port_start, self.port_end])
|
|
|
|
def rotate(self):
|
|
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출."""
|
|
self._rotate_offset += 1
|
|
|
|
def seed_offset(self, k: int):
|
|
"""워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다."""
|
|
self._rotate_offset = k
|
|
|
|
def mark_burned(self, port: int | None, cooldown_sec: float | None = None):
|
|
"""차단 감지된 포트를 쿨다운 격리 — _port() 가 만료 전까지 건너뛴다.
|
|
선제(예산) 회전된 포트는 부르지 않는다 — 불탄 게 아니므로 로테이션 복귀 시 재사용."""
|
|
if port is None:
|
|
return
|
|
self._burned[port] = time.monotonic() + (cooldown_sec if cooldown_sec is not None else self.cooldown_sec)
|
|
LOG.i(f"[proxy] 포트 {port} 쿨다운 {int(cooldown_sec or self.cooldown_sec)}s — 활성 {self.available_ports()}/{self.port_end - self.port_start + 1}")
|
|
|
|
def available_ports(self) -> int:
|
|
"""쿨다운 중이 아닌 포트 수(관측·알림용)."""
|
|
self._prune_burned()
|
|
return (self.port_end - self.port_start + 1) - len(self._burned)
|
|
|
|
def _prune_burned(self):
|
|
now = time.monotonic()
|
|
self._burned = {p: t for p, t in self._burned.items() if t > now}
|
|
|
|
def _port(self) -> int:
|
|
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.
|
|
쿨다운 중인 포트는 건너뛰고, 전 포트가 쿨다운이면 만료가 가장 임박한 포트를 쓴다(가용성 우선)."""
|
|
n = self.port_end - self.port_start + 1
|
|
bucket = int(time.time() // (self.session_minutes * 60))
|
|
self._prune_burned()
|
|
for k in range(n):
|
|
port = self.port_start + ((bucket + self._rotate_offset + k) % n)
|
|
if port not in self._burned:
|
|
return port
|
|
return min(self._burned, key=self._burned.get)
|
|
|
|
@property
|
|
def current_port(self):
|
|
return self._port() if self.enabled else None
|
|
|
|
def playwright_proxy(self) -> dict | None:
|
|
"""Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용)."""
|
|
if not self.enabled:
|
|
return None
|
|
return {
|
|
"server": f"http://{self.host}:{self._port()}",
|
|
"username": self.username,
|
|
"password": self.password,
|
|
}
|
|
|
|
def _proxy_url(self, port: int) -> str:
|
|
return f"http://{quote(self.username)}:{quote(self.password)}@{self.host}:{port}"
|
|
|
|
async def healthcheck(self, timeout: float = 6.0) -> tuple[str | None, int | None]:
|
|
"""시작 프리플라이트: 현재 포트로 egress IP 확인, 실패하면 회전하며 살아있는 포트를 찾는다.
|
|
반환: (egress_ip, port) 성공 / (None, None) 전 포트 실패. residential IP 는 실행 중에도
|
|
죽으므로 이건 '빠른 실패+가시성'용이고, 실제 회복은 런타임 IP 회전이 담당한다."""
|
|
if not self.enabled:
|
|
return None, None
|
|
n = self.port_end - self.port_start + 1
|
|
for _ in range(n):
|
|
port = self._port()
|
|
try:
|
|
async with httpx.AsyncClient(proxy=self._proxy_url(port), timeout=timeout) as c:
|
|
r = await c.get("https://ip.decodo.com/ip")
|
|
if r.status_code == 200:
|
|
return r.text.strip(), port
|
|
except Exception as ex:
|
|
LOG.d(f"[proxy] 포트 {port} 헬스체크 실패: {type(ex).__name__} → 회전")
|
|
self.rotate()
|
|
return None, None
|