fix(lps): 대역폭 비용 정확화 — CDP 실제 전송 바이트로 계측

지금까지 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>
This commit is contained in:
민헌 2026-07-09 20:27:11 +09:00
parent 8a594b667d
commit 0cd817a61d
3 changed files with 30 additions and 5 deletions

View File

@ -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"`.
> **⚠️ 가격의 의미 (배송비)**

View File

@ -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

View File

@ -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