기존 ops-monitor 는 임계 초과가 지속되면 30초마다 같은 웹훅을 반복 발송했고
(쿨다운 없음), 해소 여부도 알 수 없었다. 감시 항목도 큐 지표 4종뿐이었다.
- common/alerts.py AlertManager 신설: 룰 키별 상태 관리 — 발화 1회 +
쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드, 해소 시 회복
알림 1회. sender/clock 주입으로 네트워크·대기 없이 단위 테스트.
- 워커 ops-monitor 를 AlertManager 로 이관(기존 4룰 유지) + 신규 2룰:
db_pool(풀 포화율 ≥ LPS_ALERT_POOL_PCT 90%) ·
source_fail:<src>(최근 30분 시도 ≥ LPS_ALERT_SOURCE_FAIL_30M(5) & 성공 0
— 쿼터 소진·셀렉터 드리프트·전면 차단 신호).
- DBSessionManager.pool_status(): 전 엔진 합산 checked_out/capacity/pct.
- SearchAdapter 에 시간 윈도우 성공/실패 카운터(recent_stats) — 누적
카운터로는 '최근 30분 성공 0건'을 볼 수 없어 추가. 쿠팡(브라우저)·
네이버(API) 성공/실패 지점에 배선.
- API 자체 풀 모니터: lifespan 백그라운드 태스크(run_pool_monitor) —
대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있다.
/v1/lps/ops 에 pool_checked_out/pool_capacity/pool_pct 노출(스모크 확인).
- 테스트 9건 추가(발화·쿨다운·회복·룰 독립·윈도우 카운터·풀 현황), 전체 135 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
쿠팡 크롤 IP 를 '막힐 때까지' 쓰던 방식을 '막히기 전에 교체'로 전환한다.
- 요청 예산(LPS_IP_REQUEST_BUDGET, 기본 3): IP당 요청 수가 예산에 닿으면
차단 전에 선제 회전. 실측상 5회 부근 차단 이력이 있어 보수적으로 3회.
선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용된다.
- 포트 쿨다운(LPS_PORT_COOLDOWN_SEC, 기본 max(sticky,30분)): 차단 감지·
전송오류 포트는 격리하고 _port() 가 건너뛴다. 전 포트 쿨다운이면 만료
임박 포트 사용(가용성 우선). 포트 수는 config 범위에서 동적 산출.
- 차단 재시도 소진 시에도 회전 예약 — 불탄 포트로 다음 검색을 하지 않음.
- ip_session 테이블 신설: 세션마다 요청 수·성공/차단·종료 사유(budget/
block/proxy_error/window/idle/shutdown)를 기록. bot_detection 과 달리
무사 종료도 남아 예산 상한 튜닝의 원천 데이터가 된다(쿼리 database.md).
models.py·migrations·init.sql(lps_db 섹션) 동행 갱신, dev DB 적용 완료.
- 테스트 17건 추가(쿨다운·예산 판정·세션 기록·CRUD), 전체 126 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
지금까지 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>
WORKER_CONCURRENCY 를 늘려도 공유 브라우저 lock 때문에 직렬화되던 문제를 고쳐 진짜 병렬 검색.
- worker_main: _build_worker(i) 로 워커마다 자립 세트(브라우저 어댑터·AI·핸들러) 생성.
프로필 분리(user_data_dir_w{i}, ProcessSingleton 충돌 회피) + 워커별 다른 프록시 포트
(proxy.seed_offset 로 100포트를 균등 분할=다른 IP). naver/judge/keyword 도 워커별(공유상태 경합 제거).
프리플라이트는 대표 프록시로 게이트 1회 확인.
- proxy.seed_offset(k): 워커 시작 포트 분산.
- loadtest.py: N개 상품 제출→폴링→처리량·지연(p50/p95)·AI/DECODO/총비용 집계.
실측(동시성2, 4상품): 순차합 323s→벽시계 181s(~1.8x), 상품당 $0.0071, 1000건 ~$7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
한 검색의 소요·DECODO 비용을 크게 낮춘다(실측: 110s→52s, proxy 8MB→2MB, 총비용 70%↓).
- 폴백 병렬화: _enrich_with_fallback 의 순차 for-loop → gather 동시 크롤. 각 몰 어댑터는
별 브라우저 인스턴스라 병렬 안전. 소요=합→최댓값. AI usage 계측 동시성 불변식 주석 명시
(judge.last_usage 세팅~읽기 사이 await 없음 → asyncio 협조 스케줄링상 안전).
- 대역폭: blocked_resource_types 를 어댑터별 설정화. 오픈마켓은 이미지/미디어/폰트만 차단
(CSS/JS 유지 — 챌린지/렌더 보호). 격리 실험으로 차단 유무가 ESM 챌린지 성패에 무관함 확인
(실패 원인은 IP평판/headless). st11 라이브 0.11MB 검증.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.
- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
(max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
(detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
검색이 소모하는 리소스/비용/시간을 잡 단위로 집계해 result.metrics 로 적재(API/FE 노출).
지금까진 타임스탬프만 있고 실제 비용 동인(AI 토큰·대역폭)은 버려지고 있었다.
- services/metrics.SearchMetrics: duration_ms + ai(calls/tokens/est_cost_usd, gpt-4o-mini 단가)
+ crawl(fetches/html_bytes/malls_crawled) + source_ms
- AI 클라이언트: resp.usage 를 last_usage 로 노출(그동안 폐기하던 토큰)
- 어댑터: last_bytes(처리 HTML 바이트) 노출 — naver/coupang/browser_base 공통
- handler: 각 fetch 타이밍+바이트, AI 호출 토큰을 metrics 로 누적 → 결과에 스냅샷
- FE: 작업 카드에 원가 4타일(소요/AI비용/토큰/크롤 트래픽)
- 테스트 2종. ⚠️ html_bytes 는 대역폭 근사(오픈마켓 리소스 미차단분 제외=하한), CDP 정확화는 백로그
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
기존엔 Akamai JS 챌린지 마커만 감지해, Edge Access Denied(errors.edgesuite.net)와
'사용권한이 제한된' 권한제한 페이지가 blocked=False 로 오판됐다. 그 결과 IP 회전·
bot_detection 기록이 누락되고 같은 IP 로 재시도만 반복하다 DEAD 로 빠졌다.
- 마커 3계열로 확장(Akamai / Edge Deny / 권한제한)
- 짧은 HTML 폴백(_MIN_RESULT_HTML): 0건인데 <10KB 면 미지의 차단으로 간주
(정상 '검색결과 없음'은 전체 chrome 포함이라 큼)
- 감지를 순수 함수 detect_block(html, product_count) 로 분리 — 단위 테스트 6종
검증: 신라면 '사용권한이 제한된'(3383B) 감지 → IP 회전 → 새 IP 59건 복구.
파서 배송 분류 테스트도 함께 추가(shared fixture).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NormalizedProduct 에 shipping_type(rocket|rocket_merchant|free|paid|None) 추가.
쿠팡 파서: 로켓 뱃지(img src)로 유형, '무료배송'/'배송비 X원' 텍스트로 금액 판별
(상품명 '무료배송' 오탐은 이름 제거 후 매칭). 네이버 lprice 는 배송비 제외
상품가라 배송 필드 None — 카탈로그 '배송비포함 최저가'와 다른 이유를 docs/api.md 에 명시.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
설정이 .env(시크릿)+toml(설정)로 갈려 있던 것을 config.local.toml 하나로 통합.
DB 비번이 이미 toml 에 있어 분리 기준이 임의적이었고, backend(하우스 패턴)도
toml 단일이라 일관성 확보. 환경별로 바뀌는 값(DB_HOST 등)만 env override 유지.
- config_models: NaverConfig(keys 로테이션)·OpenAIConfig·DecodoConfig 추가
- server_configs: 3개 로드, load_dotenv 제거(python-dotenv 의존성도 제거)
- proxy/naver/similarity/keyword/worker_main: os.environ → config 객체 참조
- config.local.toml.example: [NaverConfig]/[OpenAIConfig]/[DecodoConfig] 섹션
- .env/.env.example 삭제, README/주석 갱신 (배포는 toml 마운트 or env override)
- tests: DecodoConfig 기반으로 갱신 → 전체 44/44
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
대시보드 실측 반영 — Decodo residential 은 포트 기반 sticky(gate:10001..N,
고정 user/pass, 각 포트=sticky 세션). IP 회전 = 포트 순환.
- proxy.DecodoProxy: 시간창 기반 포트 선택(창 안 동일 IP, 창 지나면 다음 포트=새 IP).
_UNSET sentinel 로 '미지정(env)' vs '명시적 빈값' 구분(테스트 결정성)
- coupang/adapter: 이미지/미디어/폰트/CSS 차단(block_resources) — per-GB 대역폭 대폭 절감,
Akamai(JS)·상품파싱엔 무영향. 라이브 검증: 차단 ON 에도 Akamai 통과+파싱 정상
- .env.example: DECODO_HOST/USERNAME/PASSWORD/PORT_START/PORT_END/SESSION_MINUTES
- tests: 포트범위/시간창 안정성/활성조건 5건 → 전체 44/44
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
datacenter IP + Akamai 대응. 매 요청 IP 변경은 쿠키-IP 불일치로 재챌린지를
유발하므로, sticky 세션(일정 시간 같은 IP) + 주기적 회전 방식 사용.
- search/proxy.DecodoProxy: username 에 -session-<시간창id>-sessionduration-<분> 부착
→ 창 안에선 같은 IP, 창이 지나면 새 IP. 자격증명은 .env(DECODO_*), 4개 다 있어야 활성
- coupang/adapter: proxy 주입 + 세션창 경과 시 브라우저 재기동(IP 회전). 무프록시면 직접 연결
- worker_main: 쿠팡에만 DecodoProxy 경유(네이버는 공식 API라 미적용)
- .env.example: DECODO_HOST/PORT/USERNAME/PASSWORD/SESSION_MINUTES 슬롯
- tests: 활성조건·sticky username·server형식·세션창 회전 4건 → 전체 43/43
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
네이버 쇼핑 오픈API 어댑터(크롤링 불필요) + 핸들러를 다중 소스로 확장.
쿠팡(브라우저)+네이버(API)를 동시 검색·병합해 교차 최저가를 뽑는다.
- search/naver/adapter: httpx + 오픈API + 키 로테이션(429/403 순환), .env 키 로드
- search/naver/transform: 순수 변환(태그/엔티티 정리, lprice). 가격비교(catalog) lprice 는
'여러 판매자 중 최저가'라 최저가 솔루션엔 핵심 → 유지
- handler: asyncio.gather 동시 검색 + 소스별 실패 격리(일부 죽어도 결과) + 전체 실패 시 잡 실패
- worker_main: adapters={coupang, naver}
- tests: 네이버 변환 + 병합/실패격리/전체실패 4건 → 전체 31/31
- 라이브: 쿠팡30+네이버30 병합 top-N 최저가(소스 라벨 포함)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
소스 어댑터 패턴으로 크롤링을 시작한다. 쿠팡은 Akamai Bot Manager 의
JS 행동 챌린지라 curl_cffi 단독 불가 → Patchright(스텔스 Playwright) +
실제 Chrome(channel=chrome)으로 챌린지를 통과하고 selectolax 로 파싱한다.
- contract: SearchAdapter(ABC)·NormalizedProduct·AdapterHealth·AdapterError
- coupang: adapter(브라우저 재사용, 챌린지 1회)·parser(순수)·selectors(외부화, webpack 해시 prefix 매칭)
- 공용: rate_limiter(2~8s 랜덤)·proxy(무프록시 off 인터페이스)·util.parse_price
- 가격 파싱: 단위가격 '(1개당 44,400원)'의 앞 '1' 오인 방지 — '원' 앞 숫자 앵커링
- deps: curl_cffi·selectolax·patchright(nodriver 는 py3.14 버그로 대체)
- tests: 파서 회귀(소형 fixture) — 라이브 2회 검색 + 파서 3/3 통과
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>