웜업이 인터랙티브 Turnstile(IP 평판 나쁨)로 실패하면 다른 IP 로 회전해 최대 3회 재시도.
gmarket 커버리지 플래키함 완화(100 IP 풀에서 좋은 IP 를 찾을 확률↑).
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>
- 임시 FE: 총 비용 타일에 AI·DECODO 분해, 크롤 트래픽에 프록시 경유 바이트(과금분)
- config.local.toml.example: DecodoConfig.cost_per_gb 안내
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 렌더: 소요시간(+소스별), AI 비용$·호출수, AI 토큰(in/out),
크롤 트래픽(html bytes·fetch수·크롤 몰).
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>
by_mall/top 에 등장하는 gmarket·auction·st11 을 소스 색상 태그(+‘크롤’ 뱃지)로 표기.
몰별 최저가 막대도 소스별 색. 폴백으로 크롤된 몰임을 UI에서 구분.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
몰이 열린 집합이라 와이드 컬럼(gmarket_*, st11_* …) 대신 JSONB 한 컬럼으로 담는다
— 몰 추가 시 마이그레이션 0. naver/coupang/final 3선 컬럼은 그래프 하위호환 유지.
- models: price_history.by_mall JSONB 추가
- crud: record/list 에 by_mall 왕복(json.dumps + CAST jsonb)
- handler: _price_snapshot 에 summarize_by_mall 적재, final 은 소스무관 전체 최저로
- protocol/service: history API 응답에 by_mall 노출
- migrations/2026-07-09: 기존 dev DB 동기화용 ALTER(추적 파일)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
API 통신 확인용 단일 파일 프론트(외부 CDN 0, SVG 차트 자체 구현).
검색 요청 → 잡 폴링(파이프라인 퍼널·소스별 건수) → 최저가 히어로/상품표,
최저가 이력 3선 그래프(네이버·쿠팡·최종), 몰별 최저가 막대, 배송 뱃지,
헤더 헬스·큐 상태 실시간. CORS 매칭 위해 :5173 서빙(serve.sh, 대화형).
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>
네이버 오픈API 결과엔 G마켓·옥션·11번가 등이 이미 mallName 으로 들어오는데
최저가 1건만 쓰고 몰 정보를 버리고 있었다. summarize_by_mall() 로 매칭 후보를
(소스, 판매몰)별 최저가로 축약해 result.by_mall(가격 오름차순)에 노출한다.
추가 요청 0건으로 오픈마켓 몰별 최저가를 확보 — 별도 크롤러 불필요.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
같은 상품 반복 검색 시 최저가를 스냅샷으로 적재 → 네이버/쿠팡/최종 3개 선 그래프.
배치 아님(조회된 상품만, 실제 검색 시각에 기록) — 트래픽/리소스 절약.
- price_history 테이블: product_code·triggered_at(X축)·naver/coupang/final 최저가+상세·outcome
- crud/price_history: record() + list_by_product(시각 오름차순)
- handler: AI 매칭 후 소스별 min + 전체 min 스냅샷 기록(_price_snapshot).
found/not_found 기록, 네거티브 캐시 히트·기술실패는 미기록
- API: GET /v1/lps/products/{product_code}/history → 그래프 데이터(시각 오름차순)
- tests: 스냅샷 계산/기록·조회/핸들러 기록규칙/API → 전체 54/54
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
파이프라인의 비워둔 슬롯(이상치 뒤·top-N 앞)에 OpenAI 유사도 판정을 결합.
"스탠리 텀블러" 검색 시 빨대마개·커버 등 호환 액세서리가 최저가로 올라오던
문제를 해결한다(기계적 최저가 → 같은 상품 최저가).
- ai/similarity: SimilarityJudge(OpenAI structured output). 액세서리/부품/다른규격 불일치 판별
- pipeline/core: apply_filters + rank_result 로 분리(AI 를 그 사이에 끼움), run_price_pipeline 동작 불변
- handler: judge 주입 시 ai_match STAGE 추가(필터 후 후보만 판정 → 토큰 절약), 미주입 시 생략
- worker_main: OPENAI_API_KEY 있으면 판정 ON
- requirements: openai / tests: fake judge 필터링 검증 → 전체 32/32
- 라이브: '스탠리 퀜처 887ml' → ai_match(60→15) → 실제 텀블러 top-6(액세서리 제거)
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>
키는 코드/커밋이 아닌 환경변수로 주입한다(감사: 시크릿 하드코딩 금지).
config.local.toml=비-시크릿 설정, .env=시크릿으로 분리.
- .env.example(템플릿, 커밋) + .env(로컬, 미추적)로 키 슬롯 제공
· NAVER_CLIENT_ID/SECRET(+로테이션 _2..), OPENAI_API_KEY, AZURE_OPENAI_*
- server_configs 가 기동 시 lps/.env 를 load_dotenv 로 환경변수 주입
- requirements: python-dotenv 추가
- README: .env 셋업 단계 안내
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
검색 결과를 실제 최저가로 정제하는 파이프라인을 핸들러에 결합한다.
검색→필터→이상치→정렬→top-N. AI 유사도 판정 슬롯은 비워둠(키 대기).
- pipeline/filters: keep_only_mall·filter_out_malls·filter_by_price_band(요청 현재가 기준 targeted 컷)
- pipeline/outliers: IQR 기반 이상치 제거(z-score 대신 — 분포 가정 없음, stdlib만)
- pipeline/core: STAGE in/out 관측 로깅 + top-N 최저가(레퍼런스 pipeline_log 계승)
- handler: 검색→run_price_pipeline 결합, 요청 price 를 밴드 기준으로 사용
- tests: 정렬/IQR(극단 저·고가 제거)/밴드/mall/빈입력/STAGE/실 fixture 7건 → 전체 27/27
- 라이브 확인: 스탠리 텀블러 40건→top-5 최저가, STAGE 카운트 노출
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
큐를 실제로 돌린다: 적재 → 워커 claim → 핸들러 실행 → 결과 저장 → DONE.
API(적재)와 워커(소비)를 분리 프로세스로(코드베이스 공유, 독립 스케일).
- worker/notify: 전용 asyncpg LISTEN 리스너. enqueue 에서 pg_notify → 유휴 워커 즉시 기상(폴링 제거)
- worker/runner: Worker(claim→처리, 처리중 heartbeat 로 lease 갱신, complete/fail) + run_reaper
- worker/handlers: job_type 별 핸들러(주입식). SEARCH=소스 어댑터 검색→정규화 결과
- worker_main: API 분리 워커 진입점(브라우저 무거워 기본 동시성 1)
- job_crud.enqueue: 삽입 시 pg_notify (중복 스킵 시엔 미발생)
- tests: drain→DONE·실패→재시도→dead·reaper 회수 후 재처리·NOTIFY 기상 4건 (전체 20/20)
- 라이브 E2E 확인: 적재→워커가 실제 쿠팡 검색(8건)→DONE
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>
backend 와 동일한 프레임워크로 lps/ 폴더를 신설한다. 구체적인 도메인
로직(크롤링/오픈API/최저가 산정)은 미정이라 골격만 구성한다.
- FastAPI 부트스트랩(web_main/router) + lifespan·CORS·gzip·로그 미들웨어 + /healthz
- TOML 설정 로더(APP_ENV) + pydantic config + DB env override
- DB 세션 매니저(논리 DB × R/W, service→람다 위임) — 엔진 lazy 라 DB 없이도 부팅
- 공통 응답 규약(gmodel) + ErrorType/DBType/DBWRType
- router→services→crud 계층 컨벤션(services/crud 는 빈 폴더로 자리만)
- 포트 9400(backend 9300·agent 9500 회피), Dockerfile/run_local_server.sh/README
- tests/test_health.py 스모크(healthz, DB 없이 통과)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
프론트 QtType 이 재협상·재견적(1·2)만 정의해, 신규협상(3)/신규견적(4)
세션은 목록 '구분'이 '-' 로 깨지고 필터로도 걸러지지 않았다.
backend·negodata enum 과 동일하게 4종으로 맞춘다.
- QtType/QT_TYPE_LABEL 에 NEW_NEGO(3)·NEW_QUOTE(4) 추가 → 목록 표시 정상화.
- filterOptions '구분' 에 신규견적·신규협상 노출 → 필터 선택 가능.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
목록 진입 시 "지금 할 일"이 먼저 보이도록 기본 정렬을 개선하고,
사용자가 마감일로 직접 훑어볼 수 있는 전체 정렬 모드를 추가한다.
- 기본(order 미지정): 협상생성·협상중을 상단 그룹으로 마감 임박순,
종료(완료·미참여·거부)는 하단 그룹으로 최근 마감순. 동일 마감은
session_id 로 tie-break 하여 페이지네이션 안정화.
- order=asc/desc: 그룹 구분 없이 전체를 마감(qt_end_time) 기준으로 정렬.
- router: order 기본값을 "asc" → None(Optional) 로 변경해 "그룹 기본"과
"명시적 전체 정렬"을 구분.
- frontend: SessionListParams.order 주석을 새 계약에 맞게 갱신(타입 무변경).
- tests: 기본=그룹 / 파라미터=전체 정렬 검증으로 분리(21 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_pick_wildcard 가 실제 선택 와일드카드와 매핑되지 않은 채 '와일드카드를
하나라도 골랐으면' 조건만으로 wild_card_budget(재원부족)을 반환해, 선택하지
않은 재원부족 멘트가 노출되는 오작동이 있었다.
- 재원부족 분기 삭제 → 해당 구간은 일반 가격협상으로 폴백. 와일드카드는
앵커<제시가≤앵커*1.02 구간의 1% 인하(wild_card_1pct)만 발동한다.
- wildcard_used 마킹을 1% 분기 안으로 이동 — 가격협상으로 돌아갈 때도
마킹하면 이후 라운드의 정당한 1% 카드까지 억제되는 부작용 방지.
- test_p7_chat: 와일드카드 발동 단언을 wild_card_1pct 로 교체.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- init 에서 미참여/거부(4·5) 세션 진입을 NEGO_NOT_PARTICIPABLE 로 차단
(participate/reject 와 동일 규칙). 프론트는 목록으로 복귀 처리.
- init 을 '진입 스냅샷'으로 격리: 화면 도중·포커스 재조회 제거, 언마운트 시
캐시 비움 → 정상 종료 후 튕김 방지 + 재진입 시 진입 게이트 재적용.
- 인디케이터를 session_status 직접 비교 → wasInProgress sticky 플래그로 전환:
진행 화면에서 실시간 완료돼도 유지, 완료/거부 결과 열람 재진입 시엔 숨김.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>