diff --git a/lps/docs/api.md b/lps/docs/api.md index c52dacf..028ab6e 100644 --- a/lps/docs/api.md +++ b/lps/docs/api.md @@ -92,7 +92,7 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \ } ``` - `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용). -- `output.metrics` = 검색 1건의 원가. `ai`(호출·토큰·추정 비용$), `crawl`(fetch 수·처리 HTML 바이트·크롤한 몰), `source_ms`(소스별 소요), `duration_ms`(전체). ※ `html_bytes`는 처리한 응답 본문 기준(대역폭 근사) — 오픈마켓은 리소스 미차단이라 실제 대역폭보다 작다(하한). +- `output.metrics` = 검색 1건의 원가. `ai`(호출·토큰·추정 비용$), `crawl`(fetch 수·**실제 전송 바이트**·크롤한 몰), `source_ms`(소스별 소요), `duration_ms`(전체). 바이트는 CDP `Network.loadingFinished`의 encodedDataLength(실제 프록시 전송량)로 측정. `proxy_bytes`(네이버 직접 제외)로 DECODO 비용 산정. - 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`. > **⚠️ 가격의 의미 (배송비)** diff --git a/lps/services/metrics.py b/lps/services/metrics.py index f977a9f..e6c965c 100644 --- a/lps/services/metrics.py +++ b/lps/services/metrics.py @@ -8,8 +8,9 @@ 핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다. 결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다. -⚠️ HTML 바이트는 '처리한 응답 본문' 기준(대역폭 근사). 쿠팡은 리소스 차단이라 실제와 근접하지만, -오픈마켓(리소스 미차단)은 CSS/JS/이미지가 빠져 실제 대역폭보다 작다(하한). 정확 대역폭은 백로그(CDP). +바이트는 어댑터가 **CDP Network.loadingFinished 의 실제 전송 바이트(encodedDataLength)** 로 측정한다 +(DOM 크기가 아님 → 리소스 차단 효과가 정확히 반영됨). DECODO 는 프록시 경유 바이트로만 과금하므로 +proxy_bytes(=네이버 직접 제외) × cost_per_gb 로 대역폭 비용을 산정한다. """ import time diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index f8bd033..4124c84 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -86,7 +86,9 @@ class BrowserSearchAdapter(SearchAdapter): self._ok = 0 self._blocked = 0 self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용 - self.last_bytes = 0 # 직전 search 의 처리 HTML 바이트(계측용) — 호출부가 await 직후 읽는다 + self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백 + self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적) + self.last_bytes = 0 # 직전 search 의 전송 바이트(계측용) — 호출부가 await 직후 읽는다 # ---- 사이트별 훅 -------------------------------------------------- @abstractmethod @@ -152,12 +154,31 @@ class BrowserSearchAdapter(SearchAdapter): self._force_recycle = False async def _close_ctx(self): + self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착 if self._ctx is not None: try: await self._ctx.close() finally: self._ctx = None + def _add_net(self, event): + """CDP Network.loadingFinished 콜백 — 실제 전송 바이트(encodedDataLength) 누적.""" + try: + self._net_bytes += int(event.get("encodedDataLength", 0) or 0) + except Exception: + pass + + async def _ensure_net_meter(self, page): + """CDP 네트워크 계측 세션 부착(컨텍스트당 1회). 실패(미지원)하면 DOM 크기로 폴백.""" + if self._cdp is not None: + return + try: + self._cdp = await self._ctx.new_cdp_session(page) + await self._cdp.send("Network.enable") + self._cdp.on("Network.loadingFinished", self._add_net) + except Exception: + self._cdp = None + @property def uses_proxy(self) -> bool: """이 어댑터가 프록시(DECODO)를 경유하는지 — 대역폭 비용 귀속용.""" @@ -182,6 +203,8 @@ class BrowserSearchAdapter(SearchAdapter): page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page() url = self._search_url(query, limit) self._block_active = await self._blocking_now() # 챌린지 solving 중이면 차단 해제(Turnstile 보호) + await self._ensure_net_meter(page) + self._net_bytes = 0 # 이 검색의 전송 바이트만 집계 try: await page.goto(url, wait_until="domcontentloaded", timeout=40000) await self._wait_ready(page) @@ -194,7 +217,8 @@ class BrowserSearchAdapter(SearchAdapter): continue raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex - self.last_bytes = len(html.encode("utf-8")) + # 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백 + self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8")) products = self._parse(html) if products: self._ok += 1