Commit Graph

53 Commits

Author SHA1 Message Date
민헌
74db218410 feat(lps): 2단계 — price_history 에 몰별 확인 상태 저장
1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는
몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다.

- price_history.sources (JSONB): 몰별 상태를 그대로 담는다.
  {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}
  열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침).
- price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 —
  소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로
  흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다.
- 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용).
- _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는
  sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정).
- 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**)

검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만
partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다.
JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정.

테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:57:19 +09:00
민헌
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
민헌
152aed9908 fix(lps): 한 소스가 막혀도 잡을 정상 종료 — '탐색 중'에서 멈추던 원인
운영 로그(2026-08-06)에서 드러난 문제. 쿠팡이 막힌 동안 **네이버가 매번 40건을 가져왔는데도**
잡이 전부 DEAD 로 갔다:

  fail 3c0bcaff → DEAD ({'coupang': {'error': 환경 차단}, 'naver': {'count': 40}})

그리고 DEAD 는 price_history 에 행을 남기지 않는다. 이를 폴링하는 negodata 최저가 모달은
받을 결과가 영영 없어 '탐색 중'에서 멈춘다(사용자 화면 확인). 즉 프론트 버그가 아니라
**백엔드가 답을 안 준 것**이다 — 두 증상이 같은 뿌리였다.

원인: 핸들러가 '하나라도 실패했나'(bool)만 보고 무조건 raise 했다. 한 소스가 막혔다고
다른 소스가 멀쩡히 가져온 결과까지 버린 셈이다.

바꾼 것:
- _search_round 가 **성공한 소스 목록**을 돌려준다(bool → list).
- 살아있는 소스가 하나라도 있으면 그 결과로 진행한다. **전부 죽었을 때만** 재시도한다
  ('없음'이라 단정할 수 없는 건 그때뿐이다).
- 결과에 sources_ok/sources_failed/partial 을 실어 커버리지를 드러낸다(부분 결과 로그도 남김).
- 부분 실패 상태의 not_found 는 **네거티브 캐시에 넣지 않는다** — 막힌 소스엔 있었을 수 있는데
  '없음'으로 굳히면 TTL 동안 재검색이 막힌다(사용자가 '다시 검색'을 눌러도 캐시 히트).
- 전부 실패 + 재시도 소진이면 DEAD 대신 outcome='error' 로 **이력을 남기고** 종료한다.
  화면이 무한 대기 대신 결과를 받는 게 중요하다.

negodata 는 변경 없이 받는다(outcome='error' → success_yn=false, fail_reason='error').

테스트 5건 추가·1건 갱신(옛 계약 '한 소스 실패 시 raise' 를 새 계약으로 교체), 전체 274 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:34:36 +09:00
민헌
8d15fe6bb0 fix(lps): 네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰이었다
컨테이너를 로컬에 재현해 규명했다. '컨테이너가 문제'가 아니라 **UA 가 리눅스라고 말하는 것**이
원인이다. 네이버는 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다.

실측(같은 이미지·같은 KR 프록시, 서로 다른 IP 3개씩):
  X11; Linux x86_64        0/3 통과   전부 405 + wtm_captcha (~50KB)
  Macintosh; Intel Mac     3/3 통과   전부 200 · 6건 · ~1.0MB
  안드로이드·아이폰 모바일    0/2       418 '비정상적인 접근'(2.6KB, 회전 무효 하드차단)
프록시 없이 같은 집 IP 로도 호스트 통과 / 컨테이너 차단이 재현돼 IP·게이트웨이는 배제됐다.
맥에서 잘 되던 이유도 이걸로 설명된다.

**405 가 열쇠였다** — JS 가 돌기 전에 HTTP 계층에서 거부당한다. 그래서 그동안 의심하던
WebGL·폰트·plugins 는 애초에 원인이 될 수 없었다(확인차 --enable-unsafe-swiftshader 로
WebGL 을 살려봤지만 405 그대로였다).

조치:
- services/search/user_agent.py: 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환. Chrome 버전은
  `--version` 으로 실제 값을 읽어 유지한다 — 하드코딩하면 컨테이너 Chrome 업데이트 시
  UA 와 엔진이 어긋나 그 불일치가 새 봇 신호가 된다. 조회 실패해도 크롤을 막지 않는다.
- NaverShopAdapter.mac_ua_on_linux = True (쿠팡은 잘 통과하므로 기본 False 그대로 — 멀쩡한 걸
  건드리지 않는다). 맥/윈도우에서는 자동 미적용.
- 실제 어댑터로 컨테이너 검증: '생수' 40건, '스페셜티 원두 1kg' 40건 통과.

부수:
- fingerprint.judge: WebGL 이 **아예 없는** 경우를 OK 로 흘려보내던 판정 버그 수정(컨테이너
  재현 중 발견 — 소프트웨어 렌더링보다 더 튀는 값인데 침묵했다). UA 플랫폼 항목 추가.
- docs/operations.md: '미해결' 절을 원인·수치·조치·확인법으로 교체.
- fingerprint.py: JS 위장이 이 스택에서 불가능하다는 실측 기록 유지(재시도 방지).

테스트 8건 추가(리눅스에서만 보정·실제 버전 유지·조회 실패 폴백·네이버만 opt-in), 전체 270 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:40:14 +09:00
민헌
9380760869 fix(lps): 네이버 컨테이너 차단 — 계측 CDP 토글 + 지문 판정 + 폰트 보강
운영 실측으로 범위가 좁혀졌다: 같은 컨테이너에서 **쿠팡은 60건 정상, 네이버만** 매 IP
첫 요청부터 wtm_captcha. kr_host 도 정상이다(ensure_ports 에 kr.decodo.com, 마커가 해외 IP
하드차단 '비정상적인 접근'(2.6KB)이 아니라 wtm_captcha(43~57KB)). IP·게이트웨이가 아니라
브라우저 지문이 남은 후보다.

**먼저 확인한 것 — 지문 위장은 이 스택에서 불가능하다(중요)**
WebGL 렌더러(컨테이너=SwiftShader)를 실기기 이름으로 덮으려 했으나 주입 경로가 둘 다 막혔다:
  context.add_init_script()  무반응. 단순 마커(window.__M__) 주입조차 undefined 다.
                             patchright 가 무력화한다 — 이 API 는 CDP
                             addScriptToEvaluateOnNewDocument 로 구현되고 그 흔적이 탐지
                             신호라, 걷어내는 게 patchright 의 존재 이유다.
  확장(MV3, world:MAIN, document_start)  로드는 되지만 값이 바뀌지 않았다.
→ 동작하지 않는 코드를 남기지 않는다. 스푸핑은 넣지 않고, 대신 **판정**만 한다.
   재시도 방지를 위해 이 실측을 fingerprint.py docstring 에 남겼다.

**넣은 것**
- services/search/fingerprint.py: 지문 판정(항목·정상여부·관측값·왜 문제인가). 진단이 곧
  '무엇을 고칠까'로 이어지도록 이유를 함께 낸다.
- net_meter 토글: 네이버에 계측용 CDP(Network.enable)를 붙이지 않는다(기본 off).
  네이버는 '요청 가로채기 자체가 탐지 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는
  그대로 붙고 있었다 — 같은 계열이다. [WorkerConfig].naver_net_meter 로 재빌드 없이 A/B
  (config 는 마운트). 끄면 대역폭이 DOM 크기 근사로 떨어질 뿐 크롤엔 영향 없다.
  쿠팡은 잘 통과하므로 기본값을 바꾸지 않는다.
- Dockerfile.worker: fonts-noto-cjk·fonts-liberation 추가(나눔 하나뿐이면 폰트 지문이 부자연스럽다).
- diag_naver.py 가 같은 판정 모듈을 쓰도록 정리.

통과 환경 기준값(맥 실측): 의심 항목 0개 · HTTP 200 · 6건 · 1.26MB.
테스트 6건 추가(통과 지문을 문제로 몰지 않는지·컨테이너 지문을 놓치지 않는지), 전체 262 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:22:01 +09:00
민헌
7b9d895ead fix(lps): 풀 고갈 시 남의 IP 를 빌려 쓰던 문제 — 임대 없으면 크롤하지 않는다
동시 다상품 검색 점검 중 발견. 워커 3개(소유자 6)가 포트 2개/게이트웨이를 두고 경합하는
상황을 실제 코드로 돌리니, 임대를 못 받은 워커가 **남이 쥔 포트를 그대로 집어 같은 IP 로
동시에 요청**했다:

  coupang-w1   사용=70002 임대=70002
  coupang-w2   사용=70002 임대=None    ← 같은 IP 를 둘이 사용

원인은 _port() 의 계산식 폴백이다. 장부 모드에서 acquire 가 None 을 줘도 시간창 계산으로
포트를 하나 골라 돌려줬다. 포트 장부가 존재하는 이유("워커 N개가 같은 IP 에 요청을 몰면 그 IP 가
빨리 탄다" — port_registry.py 도입 배경)를 정면으로 무너뜨리는 경로다. 게다가 하필 **풀이 마른
상태 = IP 가 가장 귀할 때** 발동해, 남은 IP 를 두 배 속도로 태우는 악순환을 만든다.

→ 장부 모드에선 임대한 포트만 쓴다(없으면 None). 못 받으면 AdapterError 로 실패하고 잡이
   백오프 후 재시도한다 — 그 사이 쿨다운이 풀린다. 풀 고갈 자체는 proxy_ports_low 가 이미 운다.
→ playwright_proxy() 도 임대가 없으면 예외. 여기서 None 을 돌려주면 **프록시 없이** 브라우저가
   떠 서버 공인 IP 로 크롤하게 되는데, 그 IP 가 타면 회전으로 복구할 수 없다.

동시성 점검 결과(포트 20개/게이트웨이, 워커 3개):
  정상 24건 동시     성공 24 · 포트 중복 보유 0
  풀 고갈           성공 4/6(2건은 정상적으로 실패) · **같은 IP 공유 0**
  전면 차단          소각이 어댑터당 2개에서 멈춤(게이트웨이당 6/20) · 브레이커 6/6 트립

테스트 3건 추가(고갈 시 None 반환·남의 포트 미사용 / 임대 없는 playwright_proxy 예외 /
검색이 깔끔히 실패), 전체 256 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:15 +09:00
민헌
08afc7d4ad fix(lps): sticky 만료 회전이 저트래픽에서 안 먹던 문제 + prod 템플릿에 kr_host 누락
prod 영향 점검 중 발견. 직전 커밋(377389f)의 sticky 만료 회전은 **브라우저가 열려 있을 때만**
동작했다 — `_ctx is None` 이면 만료 검사를 건너뛰었다. 그런데 negodata 연동은 수동 트리거
전용이라 검색이 드문드문 들어오고, 그때는 유휴 정리(120s)로 브라우저가 닫힌 채 매 검색이
그 경로로 들어온다. 즉 **실사용 패턴에서만 안 먹는** 반쪽 수정이었다(377389f 커밋 메시지·README
의 '고쳤다'는 서술이 부정확했다).

실측(합성 게이트웨이·10포트, sticky 매번 경과):
  연속 검색            IP 6개 순환      ✅
  저트래픽 · 예산 3     IP 2개          예산이 대신 회전시켜 가려져 있었음
  저트래픽 · 예산 0     **IP 1개 고정**  ❌  ([DecodoConfig] 주석의 '0=시간창 회전만'이 거짓)

원인은 시계가 둘이었던 것이다. sticky 만료를 브라우저 기동 시각(_launched_at)으로 쟀는데,
브라우저는 닫혔다 열릴 때마다 시계가 되감긴다. IP 를 쥔 시간과 어긋나는 이 구조가 F1(예산
미발화)과 F4(회전 안 됨)의 공통 원인이었다.
→ 시계를 _session_started_at 하나로 통일하고 _launched_at 을 제거했다. 만료 판정은 브라우저가
   닫혀 있어도 수행한다. 세 시나리오 모두 정상 회전 확인.

**prod 템플릿 kr_host 누락**(기존 문제, 이번 변경과 무관):
config.prod.toml.example 에 kr_host 가 없어 그대로 복사하면 네이버가 국가 무지정 게이트웨이로
떨어진다. 해외 residential IP 는 '비정상적인 접근'(2.6KB) 하드차단이고 회전으로 회복 불가라
네이버 결과가 통째로 0건이 된다. kr_host + naver_ip_request_budget 을 경고 주석과 함께 추가.

테스트 2건 추가(저트래픽 sticky 만료 회전 / IP 를 쥔 시간이 수명 내면 회전 안 함), 전체 253 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:50:08 +09:00
민헌
377389f495 fix(lps): IP 로테이션 안정성 검수 — 예산이 안 먹던 근본 원인 + 차단 시 풀 소각 차단
크롤·IP 로테이션을 검수하며 찾은 결함을 순서대로 고쳤다. 의심 지점은 모두 실제 코드 경로로
재현해 확인했다(브라우저·네트워크만 mock, 프록시·DB 장부는 실물).

**① 요청 예산이 사실상 발화하지 않았다 (핵심)**
유휴 정리(close_if_idle, 120s)는 브라우저만 닫고 임대는 두는데, 재기동 때마다 _ip_requests 를
0 으로 되돌렸다. 게다가 ensure_port 의 renew 가 임대 만료를 계속 뒤로 민다 — 검색이 유휴
임계보다 뜸하고 임대(10분)보다 잦으면 **한 IP 에 영원히 고정**된다(실측: 6회 검색이 전부 같은
포트·ip_req#1). 연속 검색에서는 정상 동작해 부하 테스트로는 안 잡히고, 수동 트리거처럼
드문드문한 실사용 패턴에서만 깨진다.
파급이 하나 더 있다 — bot_detection.ip_request_no 가 항상 1 로 찍혀, operations.md 가 명시한
'1 위주면 IP 평판 / 2 이상이면 예산 하향' 진단이 통째로 무너진다. 과거 "전량 ip_req#1 이라
IP 평판 문제" 결론은 이 착시일 수 있다(문서에 경고 추가).
→ IP 세션 상태를 브라우저 수명과 분리. **포트가 실제로 바뀔 때만** 리셋한다(_begin_ip_session).
   세션 종료 기록도 포트 변경·최종 close 시점으로 옮겼다(idle 사유 소멸).

**② 환경 차단이면 회복 못 하는데 풀을 계속 태웠다**
쿠팡은 fatal 마커가 없어 컨테이너 차단 같은 '회전 무효' 상황을 구분 못 했다. 실측으로
웜업 6포트 + 잡 1건당 6포트를 30분 쿨다운에 묶어 **잡 16건이면 100포트 고갈**. 실제 장부에도
9분간 11포트 연속 소각 이력이 남아 있다(gate 사용 21 / 소각 14).
→ 서킷브레이커: **서로 다른 IP 가 연속 3개 모두 첫 요청부터** 막히면 IP 문제가 아니라고 판정,
   태우기를 멈추고 fatal 로 알린다(env_block 마커 → 기존 fatal_block 알림이 집계).
   같은 IP 반복 차단·뒤쪽 요청 차단은 세지 않는다. 성공 1회로 자동 해제(타이머 불필요).
   결과: 전면 차단 시 소각이 판정 근거 2개에서 멈춘다(웜업 6→0, 잡 6→0).

**③ 종료가 임대를 반납하지 않았다**
close() 후에도 leased_until(최대 10분)까지 그 IP 를 아무도 못 썼다 — 재시작이 잦을수록 가용
풀이 줄었다. DecodoProxy.release() 추가, close() 에서만 호출(유휴 정리는 웜 쿠키·예산 유지를
위해 그대로 둔다).

**④ 시간창 재기동이 IP 를 안 바꿨다** — 로그만 'IP 회전'이었고 renew 로 같은 포트를 붙잡았다.
sticky 수명이 끝나면 같은 포트라도 IP 가 바뀌므로 명시적으로 놓아준다.

**⑤ '검색결과 없음'을 차단으로 오인해 IP 를 태울 수 있었다**
네이버 무결과 페이지 크기는 실측된 적이 없는데 short_html 폴백이 이를 차단으로 본다.
확신도로 대응을 갈랐다 — 알려진 마커만 태우고/서킷브레이커에 세고, 미지의 짧은 HTML 은
회전·재시도까지만. 판단 근거는 bot_detection 에 계속 쌓이므로 나중에 임계를 실측할 수 있다.

**⑥** available_ports() 가 장부 모드에서 늘 최대값을 반환하는 점을 문서화(관측 경로는 미사용).
세션 마감을 멱등하게 만들어 close() 중복 호출 시 이중 기록 방지.

테스트 14건 추가(전체 251 passed). mock 하니스도 실물을 타도록 고쳤다 — 회전 시 포트가 실제로
바뀌고, 재기동 판단·IP 세션 경계는 실제 코드를 그대로 쓴다(고정 포트 mock 은 이 버그를 못 봤다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:01 +09:00
민헌
b2b5708a6a fix(lps): AI 매칭이 후보 많으면 통째로 무너지던 문제 — 배치 분할 + 규칙 충돌 제거
A/B 실험(상품 8개)에서 2개가 매칭 0~1건으로 사실상 실패했다. 수집은 40건 정상, 가격도
정확했는데 판정이 못 찾았다. 파고드니 두 가지였다.

**① 일괄 판정 붕괴(핵심)**: 후보 37건을 한 번에 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를
매기고 **전부 불일치**로 답한다. temperature=0 에서 3회 재현. 10건씩 쪼개면 같은 모델·같은
입력으로 12건이 매칭된다. 상품 3종에서 모두 재현(크리넥스 0→12, 다우니 8→23).
→ _BATCH=10 으로 쪼개 병렬 판정 후 전체 index 로 복원. 지연은 배치 1개분, 토큰은 합산.

**② 프롬프트 규칙 충돌**: "포장(개수/박스) 차이는 동일 상품" vs "용량·규격이 다르면 불일치" 가
함께 있어 '30롤 1팩'과 '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 정할 수 없었다. 최저가 관점에선
수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 쓰면 왜곡). 종류 혼동도 실측에서
오탐을 만들었다 — '프라이팬'을 찾는데 볶음팬·웍팬이 매칭됐다.
→ 규칙을 '무시할 차이 / 불일치로 볼 차이'로 갈라 재작성(수량·종류 명시).

결과(캐시 후보 재판정):
  크리넥스 30롤   매칭 0 → 5건, 최저가 **not_found → 15,500원**
  다우니 4.1L    매칭 8 → 12건, 최저 21,890 유지
  테팔 프라이팬   매칭 2 → 0건 (볶음팬·웍팬 오탐 제거 — 후보에 진짜 프라이팬이 없었다)

테스트 6건 추가(배치 분할 시 index 매핑·누락 보수처리·토큰 합산·빈 후보·상수 상한·규칙 충돌),
전체 237 passed.
2026-08-05 15:45:32 +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
민헌
5c9567919f feat(lps): 네이버 회수율 48→120건(적응형 스크롤) + 크롤 차단을 기동 즉시 알림
**회수율**: 베이스 _wait_ready 는 '고정 3회 스크롤 → 셀렉터 대기' 순서라, 프록시 지연이 있으면
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝났다. 네이버용으로 순서를 뒤집고 종료 조건을
횟수가 아니라 '카드 수가 더 안 늘어남'으로 바꿨다 — 네트워크가 느리든 빠르든 같은 결과가 나온다.
A/B(같은 IP·같은 세션, 3개 쿼리): 14·14·20 = 48건 → **40·40·40 = 120건**(전부 상한 도달).

**크롤 프리플라이트**: 웜업 대상에 naver 를 추가하고, 3회 모두 실패하면 로그가 아니라 **알림**을
쏜다. 컨테이너 워커는 크롤이 막혀도 하트비트가 살아 있어 healthy 로 보이고, 잡이 DEAD 로
쌓일 때까지 아무도 모른다(실측). 성공하면 해소 알림으로 자동 정리된다.
AlertManager 를 main 에서 만들어 웜업·ops 모니터가 쿨다운 상태를 공유한다.

**문서**: operations 에 차단 마커별 대응표(비정상적인 접근=구조적/wtm_captcha=회전)와
'컨테이너 크롤 차단' 절 추가 — 배제한 원인, Rosetta 에뮬 주의(= '이 맥에서만'일 수 있음),
배포 시 확인 순서(warmup 로그 → 호스트 비교 → 워커만 호스트 실행).

테스트 3건 추가(웜업 실패 알림·성공 해소·비크롤 소스 스킵), 전체 223 passed·0 failed.
2026-08-05 13:33:19 +09:00
민헌
b7fc327779 refactor(lps): 사문화된 네이버 오픈API 어댑터 제거 + 낡은 테스트 계약 갱신
**낡은 테스트**: test_handler_skips_record_on_negative_cache_hit 는 '캐시 히트면 이력을
남기지 않는다'를 검증했는데, 그 동작은 실측 버그였다 — 잡은 DONE 인데 price_history 에
새 행이 없어 이를 폴링하는 소비자(negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다.
코드는 이미 '캐시 히트도 이 잡의 결과이므로 기록한다'로 고쳐져 있었고 테스트만 남아 있었다.
→ 현재 계약(not_found 스냅샷 1건 기록, 가격은 null)을 검증하도록 다시 씀. 전체 220 passed·0 failed.

**오픈API 어댑터 제거**: shop.json 이 2026-07-31 종료돼 404 SE05 만 반환하고, 파이프라인은
naver_shop(크롤)로 옮겨 갔다. 되살릴 수 없는 코드를 남겨두면 다음 사람이 "키를 넣으면 되나"
하고 시간을 쓴다.
- services/search/naver/ (adapter·transform) 삭제
- NaverConfig 모델·로더·설정 섹션 3개 파일에서 제거(죽은 키)
- test_naver_transform 삭제, test_alerts 는 NaverAdapter 대신 스텁 사용
  (검증 대상인 recent_stats/_note_result 는 베이스 SearchAdapter 계약이라 무관)

**문서 정합화**: architecture(네이버 안티봇=WTM, 통과 3조건) · api(배송비가 이제 채워짐,
가격은 즉시판매가·쿠폰가 제외) · operations(kr_host·naver_ip_request_budget) · README 트리.

source 이름 "naver" 는 그대로다 — price_history·by_mall·프론트 계약은 구현 교체와 무관하다.
2026-08-05 13:26:10 +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
민헌
3b345929d4 feat(lps): 포트(IP) 장부를 DB 로 올려 멀티 프로세스에서 안전하게 — 프로필 슬롯도 분리
한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는
프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로
집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다.

proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until).
- acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다
  (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다)
- 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가
  자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거
- 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요)
- 차단·휴식은 전역이라 재시작해도 유지된다

DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고
ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해
종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다.

**프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다.
예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다
(실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가
아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다.

검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두
DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
2026-08-05 11:44:54 +09:00
민헌
495e16b951 feat(lps): IP 를 태우지 말고 한계 직전까지 쓰고 쉬게 — 네이버 예산 상향 + 휴식 개념
기존엔 네이버가 쿠팡 기준 예산(3회)을 그대로 썼다. 실측하니 체급이 다르다:
같은 KR IP 로 **12회 연속 검색까지 무차단**(IP 4개 전부 한계 미도달). 3회로 돌리면
불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 회전마다 브라우저 재기동(~20s)이 붙는다.
→ [DecodoConfig].naver_ip_request_budget = 10 (실측 12 에 여유). 쿠팡은 3 유지.

그리고 선제 회전에 빠져 있던 조각을 채웠다 — **휴식(rest)**:
예산 도달로 놓은 포트를 곧바로 다른 워커가 집으면 그 IP 의 요청률이 도로 올라가
예산의 의미가 사라진다. release(rest_sec=...) 로 sticky 수명만큼 쉬게 한다.
차단으로 태우는 burn(30분)과는 별개 상태다:
  휴식  탄 게 아님 · 짧음 · 소진 시 가장 먼저 회수
  쿨다운 차단당함 · 김 · 휴식보다 나중에 회수
회전 종류(kind)를 browser_base → DecodoProxy.rotate(kind) 로 전달해 budget 일 때만 휴식을 건다.

라이브 검증(예산 3으로 낮춰 관찰): 6회 검색 = IP 2개만 사용, 3회마다 선제 회전,
놓은 포트는 휴식 1 · 쿨다운 0 · 차단 0. 즉 IP 를 태우지 않고 로테이션만으로 돌아간다.

테스트 6건 추가(휴식 재사용 금지·만료 복귀·burn 우선·회수 우선순위·budget vs block),
전체 202 passed. _MockProxy.rotate 가 kind 를 받도록 갱신.
2026-08-05 11:16:37 +09:00
민헌
f80cece9c1 feat(lps): 프록시 포트(IP 세션) 공용 중재자 — 소스·워커 간 IP 충돌·재사용 방지
한 DECODO 계정으로 성격이 다른 두 풀을 쓰게 됐다:
  쿠팡   gate.decodo.com  국가 무지정(실측 VN·MY·BD·ID·KZ·IN·PH)
  네이버 kr.decodo.com    한국 전용(LG U+·KT·SK브로드밴드)
네이버는 해외 IP 를 즉시 하드차단한다 — 같은 포트 10091 에서 gate=차단(2,641B) /
kr=정상 14건. 같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다
(port 10061 → gate=103.99.27.55(ID) / kr=121.180.128.2(KR)) → 자원 키는 (host, port).

PortRegistry 가 프로세스 전체의 포트를 중재한다:
- 배타 임대: 한 (host,port) 는 동시에 한 소유자만. 워커 N개가 회전하다 같은 IP 로
  수렴해 한 IP 에 요청이 몰리던 문제를 없앤다(seed_offset 은 시작점만 벌렸다)
- 전역 쿨다운: 누가 태웠든 만료 전까지 아무도 못 집는다(예전엔 쿨다운이 프록시
  인스턴스별이라 다른 소스가 곧바로 재사용했다)
- 리스 만료 = sticky 수명(session_minutes) → 자동 반납 후 새 IP
- 전 포트 소진 시 가장 빨리 풀릴 포트를 회수(멈추는 것보다 낫다)
- snapshot() 을 ops 알림 페이로드에 실어 게이트웨이별 보유/쿨다운/소유자를 남긴다

DecodoProxy 는 registry 주입 시에만 임대 경로를 타고, 미주입이면 기존 동작 그대로다
(단독 사용·기존 테스트 경로 보존).

검증: 워커 2개 동시 실행 e2e — 쿠팡·네이버 병렬 수집, 삼다수에서 네이버가 최저가
획득(naver 8,960 < coupang 13,200). 테스트 14건 추가, 전체 196 passed.
2026-08-05 10:59:49 +09:00
민헌
23c1c48634 feat(lps): 네이버 최저가를 모바일 크롤로 복구(오픈API 종료 대체)
shop.json 이 2026-07-31 종료(404 SE05)되고 NCP API HUB 에도 승계되지 않아
가격을 얻을 공식 경로가 사라졌다 → 쿠팡과 같은 스택(patchright+실제 Chrome)으로 크롤 전환.

경로: msearch.shopping.naver.com (PC 는 405/418 로 막힘). 7/9 스파이크 때 모바일은
로그인 리다이렉트였는데 그 사이 열렸다.

통과 조건 3개 — 하나라도 빠지면 WTM 캡차(실측):
- **한국 IP**: 해외 residential 은 즉시 하드차단(2.6KB) → kr.decodo.com 게이트웨이
  ([DecodoConfig].kr_host, DecodoProxy(host=...) 로 주입. 쿠팡은 기존 월드와이드 유지)
- **ko-KR 로케일/시간대**: KR IP + en-US 조합을 봇으로 본다
  (BrowserSearchAdapter.context_options 훅 추가)
- **리소스 차단 금지**: route 를 걸면 즉시 캡차. image/media/font 만 막아도 동일 →
  '무엇을 막느냐'가 아니라 요청 가로채기 자체가 탐지 신호. 대신 검색당 ~3MB(~$0.009)

파서는 '정확한 상품의 최저가'를 기준으로 취사선택한다:
- 광고/슈퍼적립/브랜드블록 카드 제외(멤버십·쿠폰 조건부 가격)
- 쿠폰할인가를 price 로 쓰지 않음(조건부라 실구매가보다 싸게 잡힘)
- 가격비교('최저 N원') 카드는 유지하고 mall_name="네이버"(옛 lprice 와 같은 의미)
- **배송비 확보** — 옛 오픈API 는 필드 자체가 없어 전 소스 None 이었다
- 가격 함정 3종 회귀 테스트: 단위가격(548원)·가격노드 안의 배송비(3,900원)·정상가/할인율

source 는 "naver" 유지 — price_history.naver_lowest·MALL_BY_SOURCE·프론트 그래프 계약이
구현(API→크롤) 교체와 무관하게 살아야 한다.

테스트 12건 추가(축약 픽스처 + 합성 함정) · 전체 182 passed.
2026-08-05 09:05:38 +09:00
민헌
005ebc3d76 feat(lps): NCP NAVER API HUB 쇼핑 인사이트 클라이언트 추가
네이버가 2026-07-31 검색 오픈API 중 쇼핑·책·전문자료를 종료(유예·대체 없음)해
shop.json 이 404 SE05 를 반환한다. 후속 플랫폼인 NCP NAVER API HUB 를 붙인다.

- NaverApiHubConfig: 게이트웨이 base_url + NCP Client ID/Secret(둘 다 차야 enabled)
- services/naver_hub/client.py: X-NCP-APIGW-API-KEY-ID/KEY 인증, 오류 바디
  3형식(게이트웨이/Search/인사이트)을 NaverApiHubError 로 정규화(auth_failed·retryable)
- services/naver_hub/shopping_insight.py: POST /shopping/v1/categories.
  문서 제약(기간 2017-08-01~, 분야 최대 3개, timeUnit·device·gender·ages)을
  호출 전에 검증하고 카멜케이스 응답을 타입으로 변환
- tests: MockTransport 로 경로·헤더·오류형식 계약 검증 17건 + LPS_LIVE 스모크

주의: 허브에도 쇼핑 '검색'(상품명·가격·판매처)은 없다. 인사이트의 ratio 는
구간 내 최대값 100 기준 상대지표라 최저가 파이프라인 소스로는 쓸 수 없다.
기존 services/search/naver 어댑터는 손대지 않았다(사문화 상태 유지).
2026-08-04 11:23:26 +09:00
민헌
61b438d8d0 feat(lps): 관리자 FE 용 API 6종 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계
React 관리자 페이지(협의: 모니터링+필수 액션)의 데이터 소스.

- GET /v1/lps/jobs: 최신순 목록+총건수, status/q(상품코드·명) 필터.
  결과에 outcome·최저가·검색원가·오류를 평탄화해 목록에서 바로 보이게.
- POST /v1/lps/jobs/{id}/requeue: DEAD 재큐(attempts 리셋+pg_notify 워커
  깨움). 활성 중복(dedupe)이면 DB_ALREADY_SAME_KEY 로 거절.
- GET /v1/lps/products: 상품별 최신 스냅샷+누적 검색 수(최근 검색순).
- GET /v1/lps/stats/ip-sessions: 종료사유 분포·요청수 히스토그램·차단
  세션 최소 요청수(예산 튜닝 기준선)·최근 세션 50.
- GET /v1/lps/stats/bot: 시간대별 차단 + 최근 감지 목록.
- GET /v1/lps/stats/cost: 시간별 원가(AI/프록시 분해)+평균 소요.
- AdminService/admin_protocol/admin 라우터 신설, guard 일괄 적용.
  설정 변경 UI 는 두지 않음 — toml 단일 소스 원칙.
- 테스트 9건 추가, 전체 154 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:42:38 +09:00
민헌
c81df5bd88 refactor(lps): 설정을 TOML 단일 소스로 통합 — env/.env 이중 관리 제거
설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에
흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정).

- 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트),
  [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard),
  [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던
  LPS_* env 20여 개를 섹션으로 흡수.
- server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제.
  남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행
  스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐.
- Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker.
  이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패.
  .env.example 삭제, config.docker.toml.example 신설.
- negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key
  (+기존 관례대로 env override)로 이동.
- 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내),
  동시성/프로세스 수만 임시 override 로 유지.
- docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신.
- 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:11:31 +09:00
민헌
ca7f057e41 feat(lps): 알림 룰 4종 추가 — 데드라인·검색원가·포트 고갈·예산 누수
협의로 선정한 조기 신호 4종을 AlertManager 에 추가한다.

- deadline: 최근 1h JobDeadlineExceeded 수 ≥ LPS_ALERT_DEADLINE_1H(5).
  재시도로 살아나면 dead 룰엔 안 잡히는 크롤 행 반복 신호를 별도 집계.
- cost: 최근 1h 완료 잡 검색원가 합 ≥ LPS_ALERT_COST_1H_USD(1.0).
  비용의 87%가 프록시 대역폭 — 리소스차단 풀림·재시도 루프의 조용한
  비용 폭주를 감시. job.result 의 metrics.cost.total_usd JSONB 합산.
- proxy_ports_low: 가용 포트 비율 ≤ LPS_ALERT_PORTS_LOW_PCT(30%).
  쿨다운 격리 누적 — blocks_1h(80건)보다 먼저 우는 대규모 차단 조기
  신호. 워커별 프록시 중 가장 소진된 것 기준(min).
- budget_leak: 최근 6h end_reason=block 세션 ≥ LPS_ALERT_BLOCK_SESSIONS_6H(1).
  요청 예산(3회)을 지켰는데도 차단됨 = 예산 하향 검토 신호.
- deadline_1h·cost_1h_usd 는 queue.ops() 에 편입 → /v1/lps/ops 로도 노출.
  포트·세션 지표는 워커 웹훅 스냅샷에 포함(프록시 상태는 워커에만 있음).
- 테스트 4건 추가(ops 집계 2·포트 스냅샷 2), 전체 145 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:29:08 +09:00
민헌
e7d2c87fbe feat(lps): API guard — LPS_API_KEY 설정 시에만 /v1 에 X-API-Key 검증
외부에서 API 를 함부로 호출(비용 발생 enqueue 등)하지 못하도록 정적 키
guard 를 추가한다. '키의 존재'가 토글 — 개발(local/dev)은 env 를 비워
개방 모드(기동 시 WARN), prod 만 키를 주입한다(협의 결정).

- router/v1/validator/auth.py: X-API-Key 의존성 — secrets.compare_digest
  상수시간 비교, 콤마 구분 복수 키(무중단 키 교체), 매 요청 env 조회
  (재기동 없이 테스트 가능). /v1 라우터 전체에 적용.
- /healthz·/readyz 는 라우터 밖이라 항상 개방(LB 프로브).
- negodata lps_sync_service: LPS_API_KEY env 있으면 헤더 자동 첨부(한 곳).
- compose(lps-api·negodata-backend) LPS_API_KEY 패스스루 + .env.example.
- prod 체크리스트(operations.md): 키 주입 + lps-api 포트 비공개 + 기동
  로그 'API guard ON' 확인. api.md 인증 섹션 추가.
- 라이브 스모크: 무헤더/오키 401 · 정키 2종 200 · healthz 200 확인.
- 테스트 6건 추가, 전체 141 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:45:31 +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
민헌
3c5286aeec feat(lps): IP 선제 로테이션 — 요청 예산·포트 쿨다운·ip_session 관측
쿠팡 크롤 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>
2026-07-13 16:35:44 +09:00
민헌
463416c3a2 fix(lps): 워커 행(hang) 3중 방어 — 잡 데드라인·웜업 타임아웃·정리 루프 격리
부하테스트(N=10) 실측: 쿠팡 브라우저 재기동 경로 행으로 워커 2/3가
12~15분 정지(쿠팡 검색 1콜 880s), heartbeat 가 lease 를 계속 갱신해
reaper 회수 불가 + stuck_running=0 으로 관측 사각. 이에 대한 방어:

- P1 잡 데드라인: runner._process 에 wait_for(기본 300s, env
  LPS_JOB_DEADLINE_SEC) — 초과 시 취소·fail → 백오프 재큐/DEAD
- 웜업 시도당 60s 타임아웃 — 웜업이 어댑터 락을 쥔 채 행하면
  그 워커의 실 검색이 전부 락 대기로 동반 정지(실측 케이스)
- P2 browser reaper: close_if_idle 에 60s 타임아웃 — 한 어댑터
  close 행이 정리 루프 전체를 멈추는 것 방지(실측 케이스)
- P3 stuck_running: lease 만료 OR 실행 10분 초과(run_started_at)
  — 행 상태에서도 지표·알림에 잡히게

테스트 5건 추가(데드라인 DEAD/재큐, stuck 지표, reaper 격리)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:27:03 +09:00
민헌
ddb6f6bd2f fix(lps): 워커 로그 노이즈 2건 제거 — 리퍼 네이버 스킵 + 폴백 데드라인 cancel 제거
- browser-reaper가 close_if_idle 없는 어댑터(네이버 httpx)를 건너뛰도록 getattr 가드
  → 30초마다 반복되던 ERROR 로그 제거
- 폴백 데드라인을 wait_for(cancel) → asyncio.wait(버림)으로 변경
  → in-flight page.goto 취소 시 patchright 내부 future가 남기는
    'Future exception was never retrieved' 노이즈 제거, 페이지 어중간 상태 방지
  → 버려진 태스크는 강한 참조 집합(_abandoned_fallbacks)에 보관(GC 중도 파괴 방지),
    종료 시 콜백이 예외 회수 후 집합에서 제거
- 데드라인 테스트에 잔여 태스크 배수(drain) 검증 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:50:12 +09:00
민헌
9c2432c818 feat(lps): process_count 기반 커넥션 풀 자동 산정 — 멀티코어 풀 오버서브스크립션 방지
connection_budget(기본 40)을 두고, 기동 시 process_count 에 맞춰
pool_size/max_overflow 를 역산: (pool+overflow)×2엔진×process_count ≤ budget.
워커를 늘려도 config 가 스스로 예산을 지켜 커넥션 고갈→요청 실패를 예방.

- config_models: MainDBConfig.connection_budget 추가
- server_configs: _autosize_pool(process_count 확정 후 산정) + _apply_pool_env_override
  (우선순위 = 명시 DB_POOL_SIZE > 자동 산정 > toml pool)
- web_main: 기동 로그에 실효 풀/총커넥션/예산 출력
- env: DB_CONNECTION_BUDGET override, docker-compose 에 knob 노출
- tests: test_pool_autosize 10케이스(예산 준수·분할비·비활성·infeasible 바닥)
- docs: operations 2-1 멀티코어/풀 섹션 + loadtest README 자동산정 표

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:59:05 +09:00
민헌
eb1ff8fee1 test(lps): P5 브라우저 경로 스모크 — search 오케스트레이션 + 라이브 드리프트 감지
가장 취약·복잡한 경로(회전·재시도·차단감지)의 자동 테스트 공백을 메운다.

- mock 하니스(_MockPage/_MockCtx/_MockProxy)로 search() 를 결정론 테스트(브라우저 없이):
  정상 반환 / 차단→IP회전→복구 / 프록시전송오류→회전→복구 / 비프록시오류→실패(무회전) /
  차단 소진→blocked 실패. 5종.
- 라이브 스모크(각 어댑터 실제 사이트 검색→파싱): 셀렉터·안티봇 드리프트 감지. IP 의존·느려서
  기본 skip, LPS_LIVE=1 로 명시 실행(수동/야간). 네이버 스모크 통과 확인.

전체 96 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:25:42 +09:00
민헌
d6dd47e652 feat(lps): P4 관측·알림·워커 헬스 — readyz/ops + 하트비트/HEALTHCHECK + 임계 알림
프로덕션 운영 가시성. 행/좀비 워커 감지 + 큐/차단 지표 노출 + 임계 알림.

- API: /readyz(DB 도달성=readiness, 실패 503; /healthz=liveness와 구분).
  /v1/lps/ops(플랫 JSON): 큐 카운트 + oldest_pending_sec(큐지연) + dead_1h + stuck_running + blocks_1h.
- crud: JobQueue.ops()/ping(), BotDetectionLog.recent_count().
- worker: run_ops_monitor — 하트비트 파일 주기 갱신(Docker HEALTHCHECK 가 신선도로 행 워커 감지)
  + 임계(DEAD/차단/큐지연/stuck) 초과 시 WARN 로그 + (LPS_ALERT_WEBHOOK 있으면) Slack 호환 웹훅.
- Dockerfile.worker: HEALTHCHECK(하트비트 <120s). 임계·웹훅은 env(LPS_ALERT_*).
- 테스트: readyz/ops 2종.

검증: 컨테이너 healthy 판정, 하트비트 갱신, ops 스냅샷 정상. 91 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:21:43 +09:00
민헌
8a594b667d perf(lps): Step2/3 — Turnstile 웜업·동적 차단 + 유휴 브라우저 정리
G마켓/옥션 Cloudflare Turnstile 을 실제로 통과시키고, 쌓이는 브라우저를 정리한다.

Turnstile 규명(실측): patchright 가 콜드 ~12초에 자동 통과, 통과 후 cf_clearance 쿠키로
이후 요청은 ~5초(웜). 단 **리소스를 조금이라도 차단하면 Turnstile 지문검사에 걸려 실패**
(이미지만 막아도 실패). 하지만 **cf_clearance 확보 후엔 차단해도 재챌린지 없음**.
- 동적 차단: _block_active + _blocking_now() 훅. ESM 은 cf_clearance 있을 때만 차단
  (콜드=허용→Turnstile 통과, 웜=이미지/미디어/폰트 차단→대역폭↓). ready_timeout 25s.
  best-effort Turnstile 체크박스 클릭(인터랙티브/나쁜IP 대비).
- 웜업: 기동 시 gmarket/auction/coupang 를 1회 풀어 쿠키 선점(백그라운드) → 실 작업은 웜.
- 유휴 브라우저 정리: _last_used + close_if_idle + run_browser_reaper(유휴 120s) — 메모리 회수.
  쿠키는 user_data_dir 에 남아 재기동해도 (같은 IP면) 웜 유지.
- 테스트: 유휴 정리 2종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:20:58 +09:00
민헌
4b8dee8583 perf(lps): Step1 — 폴백 데드라인 + fail-fast로 검색 지연 상한
G마켓 챌린지가 안 풀린 채 25~70초를 먹던 문제에 상한을 씌운다. 폴백은 '있으면 좋은'
보강이라 데드라인으로 잘라도 안전(그 몰만 스킵).

- handler: 각 폴백 크롤에 fallback_deadline_sec(기본 15s) wait_for. 초과 시 스킵.
  _timed_search 예외를 BaseException 로(CancelledError 계측 포함).
- 폴백 어댑터 max_block_retries=0(챌린지 대기 2배 방지), ESM ready_timeout 22→12s, st11 20→15s.
- 테스트: 느린 폴백 데드라인 스킵 1종.

실측(동시성2·4상품): 상품당 p50 84.6s→34.2s(~2.5x), 벽시계 181s→65s(~2.8x),
처리량 1.3→3.7 상품/분, 1000건 $7.12→$5.06(~29%↓, 실패 챌린지 대역폭 절감).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:01:09 +09:00
민헌
c955a8f367 feat(lps): 프록시 전송오류 IP회전 + 시작 프리플라이트 + DECODO/컴포넌트별 비용
프록시(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>
2026-07-09 15:33:37 +09:00
민헌
69f6641c27 feat(lps): 검색 1건 원가 계측 — AI 토큰·비용 + 시간 + 크롤 트래픽
검색이 소모하는 리소스/비용/시간을 잡 단위로 집계해 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>
2026-07-09 15:14:35 +09:00
민헌
f9714c3f06 feat(lps): 오픈마켓 폴백 오케스트레이션 + 몰 dedup
사용자 규칙 '네이버로 그 몰 값 확보 성공→그 값, 실패(몰 없음)→실사이트 크롤' 구현.

- handler: found 라운드 뒤 _enrich_with_fallback — 네이버 매칭이 커버 못 한 타깃 몰만
  (canonical_mall 로 판정) 크롤 어댑터로 검색→같은상품 판정→병합. 크롤 실패는 격리(무시).
  fallback_crawl STAGE 기록.
- summarize_by_mall: 키를 (source,mall)→몰명(canonical)으로 — 네이버노출 vs 직접크롤 같은 몰
  중복을 최저가 1건으로 병합(dedup).
- card_parser: MALL_BY_SOURCE + canonical_mall() 추가.
- worker_main: gmarket/auction/st11 폴백 어댑터 등록(lazy 기동, 리소스차단 OFF).
- 테스트: 커버몰 크롤생략·미커버 크롤·실패격리·몰 dedup 4종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:39 +09:00
민헌
e365e5c6e0 feat(lps): G마켓·옥션·11번가 크롤 어댑터 + 브라우저 어댑터 베이스
네이버 폴백용 오픈마켓 크롤러 3종. 4중 복제를 피해 BrowserSearchAdapter 베이스로
브라우저 수명·프록시 회전·차단감지+IP회전 재시도·감지기록을 공유하고, 사이트 차이는
훅(_search_url/_parse/ready_selector/block_markers)으로 분리.

- browser_base: patchright 공통 machinery(쿠팡 machinery 일반화)
- card_parser: 오픈마켓 공용 카드 파서(CardConfig 주입) + util(extract_price/clean_name/shipping)
- esm: G마켓·옥션(eBay 백엔드 공유, site 파라미터 1개로 커버, '잠시만' 챌린지 대기)
- st11: 11번가 PC(공식API 판매자계정 필요라 불가). 지연로딩 → scroll_steps, '.c-card-item' 토큰
- 학습 반영: 오픈마켓은 리소스차단 OFF(렌더/챌린지 깨짐), 스턱 챌린지='잠시만' 잔존 시 차단 간주→IP회전
- 파서 결정론 테스트 7종(실렌더 카드 3개씩 경량 fixture). 라이브 검증: 신선 IP로 3사 모두 파싱 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:41:05 +09:00
민헌
8460a5d833 fix(lps): 쿠팡 차단 감지 다종화 — Edge Deny·권한제한·짧은HTML 폴백
기존엔 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>
2026-07-09 13:59:43 +09:00
민헌
313d882df4 feat(lps): 네이버 몰별 최저가 분해(by_mall) — 오픈마켓 가시화
네이버 오픈API 결과엔 G마켓·옥션·11번가 등이 이미 mallName 으로 들어오는데
최저가 1건만 쓰고 몰 정보를 버리고 있었다. summarize_by_mall() 로 매칭 후보를
(소스, 판매몰)별 최저가로 축약해 result.by_mall(가격 오름차순)에 노출한다.
추가 요청 0건으로 오픈마켓 몰별 최저가를 확보 — 별도 크롤러 불필요.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:59:15 +09:00
민헌
4863425ca5 refactor(lps): job_type 명칭 정리 — 출처 기반(레거시 negowiz 제거)
레거시 용어(negowiz)와 모호한 이름(new/single)을 출처 기반으로 변경.
우선순위 1~4는 유지, 기본값 single→manual.

- new_product(1) / manual(2, 기본) / partner(3) / batch(4)
- lps_service 우선순위 맵 + protocol 기본값/설명 + docs/api.md 표 + 테스트 갱신
- 전체 54/54

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:33:46 +09:00
민헌
5906acc48a feat(lps): 최저가 이력(price_history) — 트리거 기반 시계열 그래프
같은 상품 반복 검색 시 최저가를 스냅샷으로 적재 → 네이버/쿠팡/최종 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>
2026-07-09 11:13:28 +09:00
민헌
bca8032b79 feat(lps): 봇 감지 시 IP 회전+재시도 + 감지 이력 기록
쿠팡이 봇으로 감지(Akamai 차단 페이지)하면, 같은 IP로 재시도하던 것을
'브라우저 끄고 새 IP로 켜서 재시도'로 전환. 감지 패턴도 축적한다.

- DecodoProxy.rotate(): 시간창 무관 즉시 다음 포트(=새 IP). current_port 노출
- coupang/adapter: 0건+차단마커 감지 시 → 감지기록 → proxy.rotate()+강제재기동 →
  인라인 재시도(max_block_retries=1). IP당 요청수(ip_request_no) 추적, 재기동 시 리셋
- bot_detection 테이블 + crud: source/query/ip_request_no/proxy_port/elapsed_sec/marker/
  headless/html_len 기록 → 'IP당 몇 요청 만에 감지되나' 분석 가능
- worker_main: on_detect=BotDetectionLog.record 주입
- 로그: [coupang][BOT-DETECTED] ip_req#N port=... elapsed=...s marker=...
- tests: proxy rotate 즉시회전 + 감지기록 CRUD → 전체 47/47

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:04:07 +09:00
민헌
7911ba1745 refactor(lps): 환경설정 TOML 단일화 — .env 제거, 시크릿도 config.local.toml 로 통합
설정이 .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>
2026-07-09 10:06:00 +09:00
민헌
2481c79e3f feat(lps): DECODO 포트기반 sticky + 대역폭 최적화(리소스 차단)
대시보드 실측 반영 — 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>
2026-07-09 09:48:54 +09:00
민헌
4de85d43ed feat(lps): DECODO residential 프록시 — 쿠팡 sticky 세션 + 주기적 IP 회전
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>
2026-07-09 09:40:26 +09:00
민헌
d54dee4f24 feat(lps): 재시도/못찾음 완결 로직 — 재정제 루프 + not_found + 네거티브 캐시
'못 찾음'을 유한하게 종료. 두 재시도 축을 분리(기술=큐 attempts/백오프,
검색어=refine 라운드). not_found 는 정상 종료(DONE)지 dead-letter 아님.

- ai/keyword: LLM 검색어 생성(정밀/광역) — 원본 0매칭 시에만 지연 호출(비용 절약)
- handler: 한정 재정제 루프(원본→정밀→광역, max_rounds=3) + 명시적 outcome(found/not_found)
  · 0매칭+소스정상 → 다음 라운드, 0매칭+기술실패 → raise(큐 재시도)
  · 라운드 소진 → not_found + 네거티브 캐시 기록
- negative_cache: search_negative 테이블 + TTL(24h) upsert — 같은 상품 재요청 재검색 차단
- worker_main: OPENAI 있으면 judge+keyword_gen ON, neg_cache 상시
- tests: 재정제/not_found/캐시히트/기술실패/캐시CRUD 11건 → 전체 39/39
- 라이브: 없는상품 3라운드→not_found(30s), 재요청 캐시히트(0.00s)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:12:47 +09:00
민헌
b0a86b9720 feat(lps): AI 유사도 판정 — '같은 상품' 매칭으로 액세서리 오염 해결
파이프라인의 비워둔 슬롯(이상치 뒤·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>
2026-07-09 08:44:57 +09:00
민헌
9557c1b1af feat(lps): 네이버 어댑터 + 다중 소스 병합 검색
네이버 쇼핑 오픈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>
2026-07-09 08:38:15 +09:00
민헌
fb72ab625f feat(lps): 코어 파이프라인 — mall 필터·IQR 이상치·top-N 최저가
검색 결과를 실제 최저가로 정제하는 파이프라인을 핸들러에 결합한다.
검색→필터→이상치→정렬→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>
2026-07-08 17:06:15 +09:00
민헌
abcb58ba05 feat(lps): 워커 루프 + LISTEN/NOTIFY — 큐 소비 파이프라인 가동
큐를 실제로 돌린다: 적재 → 워커 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>
2026-07-08 16:58:58 +09:00
민헌
bebb6a71e6 feat(lps): 검색 API 라우터 — 요청 적재(enqueue) + 상태/큐 통계 조회
커머스 검색요청 계약을 프레임워크(protocol+service+RemoveNoneResponse)로 재구성.
실제 검색은 워커가 큐에서 꺼내 수행하도록 API 는 적재까지만 담당(비동기 분리).

- POST /v1/lps/search: 상품 리스트 → 상품별 SEARCH 잡 적재, product_code 로 활성 중복 방지, job_type→우선순위 매핑
- GET /v1/lps/jobs/{job_id}: 잡 상태/시도/결과 조회
- GET /v1/lps/queue/stats: 상태별 카운트(모니터링)
- protocol/lps_service 추가, job_crud.get() 단건조회, enums LPS_JOB_NOT_FOUND
- tests: 적재/중복/상태/미존재/통계 5건 (ASGI 클라이언트 + 실 lps_db) → 전체 16/16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:49:28 +09:00