지금까지 last_bytes=page.content()(렌더된 DOM 크기)라 실제 네트워크 전송량이 아니었다 — 리소스 차단 효과가 안 보이고 ESM DOM(~5MB) 과대계상 → DECODO 비용이 부정확했다. - browser_base: CDP 세션(new_cdp_session) 부착, Network.loadingFinished 의 encodedDataLength 누적 → last_bytes=실제 전송 바이트. 컨텍스트당 1회 부착, 검색마다 리셋. 미지원 시 DOM 폴백. - 실측(gmarket): 콜드(차단해제) 3.44MB → 웜(차단활성) 1.07MB(~3x↓) — 동적 차단 효과가 이제 숫자로 보임. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
3.9 KiB
Python
91 lines
3.9 KiB
Python
"""검색 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,
|
||
}
|