프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.
- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
(max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
(detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
90 lines
3.8 KiB
Python
90 lines
3.8 KiB
Python
"""검색 1건의 리소스/비용/시간 계측(관측용).
|
|
|
|
한 상품 검색이 소모하는 것을 잡 단위로 집계한다:
|
|
- 시간: 전체 소요 + 소스별 소요(ms)
|
|
- AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($)
|
|
- 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰
|
|
|
|
핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다.
|
|
결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다.
|
|
|
|
⚠️ HTML 바이트는 '처리한 응답 본문' 기준(대역폭 근사). 쿠팡은 리소스 차단이라 실제와 근접하지만,
|
|
오픈마켓(리소스 미차단)은 CSS/JS/이미지가 빠져 실제 대역폭보다 작다(하한). 정확 대역폭은 백로그(CDP).
|
|
"""
|
|
|
|
import time
|
|
|
|
# 모델별 1M 토큰당 단가(USD). input=prompt, output=completion. 모르면 0(비용 미추정).
|
|
_PRICING = {
|
|
"gpt-4o-mini": (0.150, 0.600),
|
|
"gpt-4o": (2.50, 10.00),
|
|
"gpt-4.1-mini": (0.40, 1.60),
|
|
}
|
|
|
|
|
|
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
|
inp, out = _PRICING.get(model, (0.0, 0.0))
|
|
return round(prompt_tokens / 1_000_000 * inp + completion_tokens / 1_000_000 * out, 6)
|
|
|
|
|
|
class SearchMetrics:
|
|
"""검색 1건의 누적 계측기. 스레드/코루틴 공유 X — 잡마다 새로 만든다."""
|
|
|
|
def __init__(self, model: str = "", proxy_cost_per_gb: float = 0.0):
|
|
self._start = time.monotonic()
|
|
self._model = model
|
|
self._proxy_rate = proxy_cost_per_gb # DECODO $/GB
|
|
self.ai_calls = 0
|
|
self.ai_prompt = 0
|
|
self.ai_completion = 0
|
|
self.fetches = 0
|
|
self.html_bytes = 0
|
|
self.proxy_bytes = 0 # 프록시(DECODO) 경유 바이트만 — 네이버(직접) 제외
|
|
self.source_ms: dict[str, int] = {}
|
|
self.crawled_malls: list[str] = []
|
|
|
|
def add_ai(self, usage):
|
|
"""OpenAI resp.usage(prompt_tokens/completion_tokens) 누적. None 이면 무시."""
|
|
if not usage:
|
|
return
|
|
self.ai_calls += 1
|
|
self.ai_prompt += getattr(usage, "prompt_tokens", 0) or 0
|
|
self.ai_completion += getattr(usage, "completion_tokens", 0) or 0
|
|
|
|
def add_fetch(self, source: str, html_bytes: int, ms: int, crawl: bool = False, via_proxy: bool = False):
|
|
"""외부 fetch 1건(소스·바이트·소요) 누적. crawl=폴백 크롤, via_proxy=DECODO 경유(비용 귀속)."""
|
|
self.fetches += 1
|
|
html_bytes = html_bytes or 0
|
|
self.html_bytes += html_bytes
|
|
if via_proxy:
|
|
self.proxy_bytes += html_bytes
|
|
self.source_ms[source] = self.source_ms.get(source, 0) + ms
|
|
if crawl and source not in self.crawled_malls:
|
|
self.crawled_malls.append(source)
|
|
|
|
def snapshot(self) -> dict:
|
|
ai_usd = estimate_cost(self._model, self.ai_prompt, self.ai_completion)
|
|
proxy_usd = round(self.proxy_bytes / (1024 ** 3) * self._proxy_rate, 6)
|
|
return {
|
|
"duration_ms": int((time.monotonic() - self._start) * 1000),
|
|
"ai": {
|
|
"calls": self.ai_calls,
|
|
"prompt_tokens": self.ai_prompt,
|
|
"completion_tokens": self.ai_completion,
|
|
"est_cost_usd": ai_usd,
|
|
},
|
|
"crawl": {
|
|
"fetches": self.fetches,
|
|
"html_bytes": self.html_bytes,
|
|
"proxy_bytes": self.proxy_bytes,
|
|
"malls_crawled": self.crawled_malls,
|
|
},
|
|
# 컴포넌트별 비용($) + 총합. proxy=DECODO 대역폭(근사=처리 바이트, 하한).
|
|
"cost": {
|
|
"ai_usd": ai_usd,
|
|
"proxy_usd": proxy_usd,
|
|
"total_usd": round(ai_usd + proxy_usd, 6),
|
|
},
|
|
"source_ms": self.source_ms,
|
|
}
|