"""검색 1건의 리소스/비용/시간 계측(관측용). 한 상품 검색이 소모하는 것을 잡 단위로 집계한다: - 시간: 전체 소요 + 소스별 소요(ms) - AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($) - 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰 핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다. 결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다. 바이트는 어댑터가 **CDP Network.loadingFinished 의 실제 전송 바이트(encodedDataLength)** 로 측정한다 (DOM 크기가 아님 → 리소스 차단 효과가 정확히 반영됨). DECODO 는 프록시 경유 바이트로만 과금하므로 proxy_bytes(=네이버 직접 제외) × cost_per_gb 로 대역폭 비용을 산정한다. """ 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, }