Commit Graph

7 Commits

Author SHA1 Message Date
민헌
b278f58d9c feat(lps): 1단계 — 몰별 상태 보존, '없음'과 '못 봄'을 가른다
docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.

- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
  UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
  이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
  (회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
  특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
  갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
  확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
  blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
  (confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
  이미 확정이라 덮지 않는다.

검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:47:28 +09:00
민헌
8574df4c16 feat(lps): 배송 표기 원문 기록 — 순위는 상품가 유지, 배송은 근거로 남긴다
배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비 숫자만으로는 비교가
무의미하다. 그래서 **순위는 지금처럼 상품가**로 두되, 배송 정보는 사후 판단이 가능하도록 남긴다.

실측 조사(섬유유연제·생수 2L, 두 소스):
  네이버  배송비무료 / 배송비3,000·3,900·4,500·5,000·8,800·9,000·10,000원 /
          내일배송 8.6.(목) 도착 · 오늘출발 · 빠른배송 / **배송비포함 혜택가 N원**(가격비교 카드)
  쿠팡    내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착 / 무료배송 ∙ 오늘출발 /
          모레(금) 도착 예정. 뱃지=logo_rocket_filter(로켓)·logo_rocket_merchant(판매자로켓)

- NormalizedProduct.shipping_label: 화면 문구 원문. 같은 '무료배송'이어도 주체·조건
  (와우회원·최소금액·새벽도착)이 다른데 숫자·분류로는 그게 사라진다
- 네이버: 배송비 문구 + 도착 정보를 잇는다. **_prices() 안에서 뽑는다** — 가격 노드에서
  배송비를 decompose 하기 전에 읽어야 해서(나중에 추가했다가 가격비교 카드에서 라벨이 통째로 빔)
- 쿠팡: 배송 문구가 유틸리티 클래스(fw-text-[14px])에 담겨 셀렉터로 못 집는다 → 텍스트 패턴으로
  조각을 모으고, 구분자 없이 붙은 상위 컨테이너("내일(목) 도착무료배송")는 조각 2개 이상을
  품은 것으로 판별해 버린다. selectolax 의 node.css("*") 가 자기 자신을 포함해
  '자손 매칭' 방식은 못 쓴다(실측)
- price_history.final_shipping_fee/type/label 추가(+마이그레이션). fee 는 0=무료,
  NULL=미확인(로켓 조건부) — 둘은 다른 뜻이라 기본값을 두지 않았다

교차 검증(파싱값 vs 카드 원문, 6개 규칙): 네이버 40건·쿠팡 40건 **불일치 0**.
테스트 3건 추가, 전체 231 passed.
2026-08-05 14:40:00 +09:00
민헌
6c7ff5af51 feat(lps): 최저가 오퍼의 신뢰 신호(평점·리뷰) 수집 — 네이버·쿠팡 공통
'가장 싼 값'과 '실제로 살 수 있는 가장 싼 값'은 다르다. 리뷰·평점이 전혀 없는 오퍼는
재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다 —
조금 비싼 정답보다 나쁘다. 판단 근거를 수집해 둔다.

- NormalizedProduct.rating / review_count 추가. 두 소스 모두 카드에 노출하는 값만 담아
  교차 비교가 되게 했다. **없으면 None 유지** — '리뷰 0개'와 '리뷰 정보 없음'은 다른 뜻이다
- 네이버: product_grade 의 <strong>평점</strong><em>리뷰수</em>. 텍스트를 통째로 정규식
  돌리면 '평점4.7473' 이 4.74/73 인지 4.7/473 인지 못 가르므로 노드로 분리해 읽는다.
  '1.7만' 같은 축약은 parse_ko_count 로 푼다(그대로 int() 하면 1 이 된다)
- 쿠팡: 별점은 채워진 별 개수가 아니라 컨테이너 aria-label 에, 리뷰 수는 괄호 텍스트에 있다
- price_history.final_rating/final_review_count 추가(+마이그레이션) → "리뷰 0인 최저가가
  몇 %인가"를 SQL 로 물을 수 있다. NULL 과 0 을 구분해야 해서 기본값을 두지 않았다

정렬 점검(사용자 제기): 두 소스 다 정렬 파라미터 없이 **랭킹/추천순**이다(픽스처 가격이
오름차순이 아님으로 확인). 가격순(sort=price_asc)은 차단 없이 동작하고 실측상 더 싼 후보를
찾지만(15,400→10,900), 리뷰·평점 없는 유령상품을 위로 끌어올려 미채택 — 추천순 유지.
신뢰 신호가 쌓이면 "리뷰 N 이상" 가드를 걸고 가격순을 켜는 선택지가 열린다.

e2e: TR-1/TR-2 최저가에 평점 4.89·리뷰 7,314/102,000 이 함께 기록됨. 테스트 5건 추가, 228 passed.
2026-08-05 14:00:12 +09:00
민헌
18fe18dd39 feat(lps): 회전으로 못 고치는 차단을 따로 구분 — 태우지 않고 즉시 실패 + 설정 알림
네이버 차단은 두 종류인데 지금까지 똑같이 '회전 후 재시도'로 처리했다(실측):
  비정상적인 접근  2.6KB  해외 IP — 게이트웨이 국가가 틀림. IP 를 바꿔도 결과 동일
  wtm_captcha    47~63KB IP 평판·세션 — 회전으로 회복 가능

전자를 회전시키면 100포트를 순서대로 태우기만 하고, 더 나쁘게는 **같은 게이트웨이를 쓰는
쿠팡의 풀까지 30분씩 말린다**(kr_host 를 비우면 네이버가 gate 로 폴백하므로 실제로 일어난다).

- AdapterError.fatal: 재시도해도 안 되는 구조적 실패 표시
- BrowserSearchAdapter.fatal_block_markers: 걸리면 포트를 태우지 않고 회전도 없이 즉시 실패.
  감지 기록(bot_detection)은 남긴다 — 알림이 그걸 센다
- NaverShopAdapter.fatal_block_markers = ("비정상적인 접근",)
- ops 알림 'fatal_block': 해당 마커가 1h 내 1건만 나와도 발화(자연 회복이 없어 방치하면
  그 소스는 계속 0건이다). BotDetectionLog.recent_count_by_marker 추가

라이브 검증: 일부러 해외 게이트웨이로 네이버 검색 → fatal=True 로 즉시 실패,
쿨다운 증가 0(태우지 않음), ERROR 로그에 원인·조치(kr_host 확인) 명시.
테스트 3건 추가(태우지 않음·회전 없음 / 기록은 남김 / 일반 차단은 기존대로), 전체 220 passed.
2026-08-05 13:22:37 +09:00
민헌
8b9e81777c feat(lps): 알림 확장 — AlertManager 쿨다운·회복, DB풀 포화·소스별 장기실패 룰
기존 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>
2026-07-13 16:42:12 +09:00
민헌
61bc719e28 feat(lps): 배송비 유형 분류 — 쿠팡 배송신호 파싱 + 네이버 배송비제외 명시
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>
2026-07-09 13:59:28 +09:00
민헌
e27b73c018 feat(lps): 쿠팡 검색 어댑터 — Akamai 우회(Patchright) + 정규화 파서
소스 어댑터 패턴으로 크롤링을 시작한다. 쿠팡은 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>
2026-07-08 15:59:48 +09:00