From 0cd817a61d2a68999bbbd78de583ee2464dc191f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 20:27:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(lps):=20=EB=8C=80=EC=97=AD=ED=8F=AD=20?= =?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EC=A0=95=ED=99=95=ED=99=94=20=E2=80=94=20?= =?UTF-8?q?CDP=20=EC=8B=A4=EC=A0=9C=20=EC=A0=84=EC=86=A1=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EA=B3=84=EC=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 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 --- lps/docs/api.md | 2 +- lps/services/metrics.py | 5 +++-- lps/services/search/browser_base.py | 28 ++++++++++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) 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