Commit Graph

431 Commits

Author SHA1 Message Date
0e4ddf0f43 [feat] negodata: 낙찰 방식 구분(자동/직접) + 계약가·사유 저장 위치 재정리
직접 낙찰(담당자 오프라인 계약가)을 AI 자동 낙찰과 통계·화면에서 구분하기 위해
낙찰 방식을 전용 컬럼으로 박고, 계약가·사유를 성격대로 배치했다. 처음엔 계약가·사유를
sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나 성격이 갈려 정리한다.

DB (postgres-init: init.sql 정본 + alters/2026-08-11-award-type.sql 보정, 멱등)
- quotations.award_type SMALLINT — 낙찰 방식(1=자동/2=직접). 통계 조회·집계 축이라 컬럼
- quotations.custom JSONB — 직접 낙찰 사유·처리자·시각(award={reason,by,at}). 표시·감사용
- sessions.contract_price BIGINT — 직접 낙찰 계약가. bid_price/reject_price 와 같은 협력사
  가격 축이라 세션에. 자동 낙찰은 NULL(투찰가가 곧 계약가)
- 기존 offline_award(JSONB) 데이터를 컬럼·견적 custom 으로 이관 후 키 제거

backend
- 자동 낙찰(close_and_decide)=AUTO, 직접 낙찰(claim_for_award)=MANUAL 로 award_type 기록
- 직접 낙찰: 계약가→세션 contract_price, 사유·처리자·시각→견적 custom.award (한 트랜잭션)
- 통계 계약가 = coalesce(contract_price, bid_price) — JSONB 캐스팅 제거(컬럼끼리, 인덱스·타입 안전)
- QuotationData 에 award_type·custom, SessionData 에 contract_price 노출
- 안 쓰게 된 merge_session_custom 제거

front
- 견적 상세 결과 밴드에 '직접 낙찰' 배지 + 낙찰 사유 표시(견적 custom.award.reason).
  거부·미참여 낙찰은 이미 직접 낙찰을 함의하므로 '— 낙찰' 꼬리를 떼 중복 표기 제거
- 목록 결과 배지에 '낙찰(직접)' 표기 — AI 자동낙찰과 한눈에 구분
- offlineAward()→directAwardPrice()/awardMeta() 로 교체(세션 컬럼·견적 custom 에서 읽음)
- 협상현황 표: 부가정보(회사 필드)와 의견(custom.opinion)을 별도 컬럼으로 분리
- 미응찰 건 레일 4번 칸 라벨 '최저 투찰가'→'결과'(투찰 없을 때)

테스트: negodata 110건 통과. 프론트 tsc+eslint 통과. dev DB 적용·화면 확인.
2026-08-12 10:25:26 +09:00
6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00
d02ba20520 [chore] landing: 미사용 파일 정리 — 히어로 시안 2종(console·glassmorphic)·전용 미디어, gif 재생으로 회귀한 negotiation mp4/jpg, 구 히어로 negotiation_annotated 삭제 2026-08-12 10:17:50 +09:00
ac6366a898 [feat] negosium·negodata: 협상 진행 중 거부 + 결렬 건 최종 제출가로 직접 낙찰
협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고,
거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다.

negosium
- 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출,
  주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap
- 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다
- /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합
- 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업)

negodata
- 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부
- 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가)
- 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다
- 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…'

자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
2026-08-11 10:34:07 +09:00
f1e924931f [fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용
- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리
- 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크
- 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
2026-08-11 08:49:15 +09:00
e1e519c20c [fix] negosium/backend·front: 마감된 협상이 '협상 대기'로 보이던 문제 + 종료 협상 결과 보기
- 목록 상태를 파생 계산(_effective_status): 견적 마감/마감시간 경과 + 협상생성(1) → 미참여(4).
  표시·상태필터·기본정렬 그룹에 같이 적용해 탭·KPI 카운트가 배지와 어긋나지 않게 함(DB 쓰기 없음).
  마감 일괄정리 이후 생긴 세션이 '협상 대기'에 남아 눌러도 마감 에러가 나던 헛클릭 제거.
- 미참여·협상거부 세션도 채팅 init 허용 → '결과 보기'로 지난 대화 열람. 대화 재개는 send 가
  협상중만 허용해 읽기 전용. 열 게 없는 건은 목록 has_chat(대화 이력 유무)으로 버튼 자체를 감춤.
- 종료 건은 참여 API 를 타지 않고 채팅으로 직행, 채팅 액션바는 목록 복귀만 남겨 입력 잠금.
- chain_max_round 가 단일컬럼 select 결과를 행 튜플로 읽어 매 목록 호출마다 예외 → 체인 최대차수
  0 → 옛 라운드에도 재협상 요청이 뜨고 통과되던 버그 수정(+ABC 에 중복된 죽은 구현 제거).
2026-08-10 13:57:30 +09:00
e816bfbba4 [feat] negodata: 협상 학습 메뉴 신설 — 카드별 학습 성적 · 앵커링 현황/조정 이력
agent(협상카드 강화학습)와 anchoring(구간별 자동 조정)이 쌓아온 결과를 볼 수 있는 화면이
없어 DB 를 직접 열어야 확인이 됐다. 두 축은 성격이 달라 한 화면에 섞지 않고 탭으로 나눈다.

백엔드(읽기 전용 — 값의 주인은 agent·anchoring):
- crud/learning_crud.py: learning·anchoring 스키마 경량 조회(negodata ORM 미매핑 테이블)
- services/learning_service.py + router/v1/learning: /v1/learning/cards · /anchoring

프론트:
- 사이드바 '협상 학습'(/learning) 추가, 탭 = 협상카드 | 앵커링
- 협상카드: 학습 협상·기록·타결 비율 요약 + 카드별 사용/타결/평균 점수(0 기준 좌우 막대)
- 앵커링: 구간별 현재 인하폭 + 조정 이력(표본·성공률·값 변화)

검증: 로컬 실협상 3건으로 데이터 생성 후 두 탭 실화면 확인, tsc·eslint 통과.
2026-08-10 13:24:32 +09:00
37fa65707f [fix] postgres-init: 카드 시드를 운영 현행에 맞춤 — 중반 카드 앵커가 인용 + 목표가 카드 종결 전용
협상 중반에 목표가를 부르면 이후 올려줄 금액이 없어져 남은 카드가 같은 값만 반복한다.
제안은 앵커가 → 중간가 → 목표가로 수렴해야 하므로 시드를 그 순서에 맞춘다.

- NGC-010 향후 거래 연계 · NGC-011 양보 가치 강조: 제시 금액 {target_price} → {anchor_price}
- tactic: WC-01 종결 전용 추가, WC-04 min_round 2 → 종결 전용, WC-05 종결 전용 + min_round 2,
  WC-02 min_round 2 추가 (WC-03 종전 유지)

init-data.sql · dbeaver/4_cards_reset_dbeaver.sql 동일 반영.
검증: 임시 DB 에 1_init + 4_cards_reset 적용 후 tactic·변수 확인.
2026-08-10 11:41:25 +09:00
86d50c1291 [fix] negodata/front: 윈도우 모달 한글 깨짐 — 컨테이너 font-mono 제거 + 모노 스택 한글 폴백
모달 래퍼·폼에 걸린 font-mono 가 내부 한글까지 모노 스택으로 태웠다. JetBrains Mono·Consolas 에
한글 글리프가 없어 Windows 에서 굴림으로 떨어졌다(맥은 뒤쪽 폰트가 한글을 커버해 정상으로 보임).

- 한글이 들어가는 컨테이너 15곳에서 font-mono 제거 — 숫자 정렬이 필요한 칸은 유지
- --font-mono 스택 끝에 'Noto Sans KR' 추가, index.html 에 구글폰트 링크
  (self-host Pretendard 가 못 뜰 때의 안전망 — 정상 경로는 동일 도메인 Pretendard)
2026-08-10 09:49:26 +09:00
436384fa36 [fix] agent: 중간값 카드가 목표가로 제시되던 문제 — 제안가 산식 일원화·절충 카드 목표가 이상 미발동·적용 대기 와일드카드 차단
원인 두 겹.
1) 절충 계열 가드가 타결 상한만 봐서, 중간가((앵커+목표)/2)처럼 구조상 목표가를 넘지 못하는
   값은 항상 통과했다. 앵커가 목표가에 붙으면 그 값이 곧 목표가라 목표가로 제시된다.
   → 파생 제안값이 목표가 이상이면 미발동(compute_offer). 목표가 카드는 영향 없음.
2) 제안가 확정 시 계산에 쓴 당사 직전가를 남기지 않고 그 자리를 제안가로 덮은 뒤, 멘트 렌더가
   갱신된 값으로 재계산해 "당사 A 와 귀사 B 의 절반은 A" 자기모순이 났다. 직전 커밋은 갱신을
   렌더 뒤로 미뤘으나 와일드카드는 렌더 지점이 더 뒤라 그대로 남아 있었다.
   → Offer/record_offer 로 금액과 재료(당사 직전가·협력사 제시가)를 함께 박제하고, vars_for 는
     재계산 대신 그 기록을 읽는다. 렌더 순서에 의존하지 않으므로 직전 커밋의 순서 조정은 제거.
   → 산식은 tactics.OFFER_VARIABLES 한 곳만 남기고 chat_engine 의 복사본 삭제.

곁들여: 카드 설정에서 '적용 대기'로 꺼둔 와일드카드가 견적에 담겨 있으면 그대로 발동하던 것을
차단(wild_cards.available 조건). 테이블 정의에 빠져 있던 컬럼도 보강.

검증: 단위/E2E 35건, 실협상 8시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행·
상한 초과 자동 검사) 통과.
2026-08-10 09:45:30 +09:00
a405e00bf9 [fix] agent·negodata/front: 중간값 멘트 모순 수정(갑 포지션 갱신을 치환 뒤로·원값 인용가 반올림 제외·1% 카드 역행 금지) + 계정 전환 쿼리캐시 초기화·ChatTab 카드 렌더 정리 + font-mono 한글 폴백(Windows 한글깨짐) 2026-08-07 15:48:48 +09:00
민헌
7dc925bcd4 docs(lps): 2026-08-07 세션 기록 — IP 로테이션 검수·네이버 UA 규명·결과 상태 정의
다음 세션이 이어받을 수 있게 남긴다.

- docs/2026-08-07-session-notes.md 신규: 세 덩어리(IP 로테이션 결함 6개, 네이버 컨테이너 차단이
  실은 UA 플랫폼 토큰이었다는 규명, '못 봤다'를 '없다'고 말하던 문제의 4단계 해결)와
  **되돌리기 전에 볼 근거**(예산을 IP 에 묶는 이유, 태우지 않는 판정, 임대 없이 크롤 금지,
  UA 는 플랫폼만 바꾸고 버전은 실값, 상태는 하나로 저장하고 표시에서 접기).
- 미해결: 쿠팡 간헐 차단 — IP 에 따라 갈리므로 환경이 아니라 평판. 쿠팡만 gate(해외 IP)를 쓰는 게
  유력한 원인이고, 네이버처럼 kr_host 로 바꾸는 검증이 중단된 상태.
- 다음 할 일 5가지(운영 마이그레이션 → 쿠팡 KR 검증 → 예산 튜닝 → AI 매칭 정확도 → admin 잡목록).
- 주의사항 5가지: **마이그레이션→코드 순서 강제**(역순이면 최저가 조회가 통째로 실패, 재현 확인),
  워커 켜고 pytest 하면 test_job_queue 3개 깨짐, JS 지문 위장은 patchright 가 막아 불가,
  로컬 negodata-front 는 node_modules 가 named volume 이라 재빌드로 의존성이 안 늘어남.
- README 문서 표에 링크 추가.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:02:25 +09:00
민헌
0c6c431f0a Merge branch 'feat/source-state'
'못 봤다'를 '없다'고 말하던 문제를 4단계로 해결한다. 상태를 하나로 정의·저장하고 표시 단계에서
보는 사람에 맞게 접는다 — 운영자(lps-admin)는 7상태 그대로, 실사용자(negodata)는 셋으로.

1) SourceState 7상태 — 어댑터가 이미 알던 '봤다/못 봤다' 구분을 핸들러가 버리지 않게 한다
2) price_history.sources/partial — 그 구분을 담을 자리(by_mall 은 가격 있는 몰만 담는다)
3) lps-admin — 몰별 상태·차단 마커·조치 힌트(blocked=자동회복 vs env_blocked=사람이 고쳐야)
4) negodata — '–'(확인했고 없음)와 '확인 못함'(못 봄)을 가르고, 후자는 재검색 대상으로 남긴다

⚠️ 배포 순서: 마이그레이션 2개(6_lps_2026-08_dbeaver.sql · 2026-08-07-iilp-source-state.sql)를
   **코드보다 먼저** 적용해야 한다. negodata 가 ORM 전체 엔티티를 조회하므로 컬럼이 없으면
   최저가 조회가 통째로 실패한다(재현 확인). 역순(마이그레이션 후 옛 코드)은 안전하다.

실환경 검증: 실제 크롤 결과로 네이버 no_match→'–', 쿠팡 env_blocked→'확인 못함' 확인.
잡 DEAD 0건, 네거티브 캐시 미오염, 포트 소각 8/100 에서 차단.
2026-08-07 14:46:02 +09:00
민헌
67612b477c Merge remote-tracking branch 'origin/main' into feat/source-state 2026-08-07 14:36:41 +09:00
민헌
6e401615eb fix(negodata): '일부 확인 못함'을 결과 열에도 표시 — 몰 칸만 고쳐 놓친 부분
실화면 확인에서 드러났다. 쿠팡 칸은 '확인 못함'으로 잘 나오는데 결과 열이 '변동 없음'이었다.
못 본 몰이 있는데 '변동 없음'이라 하면 사용자는 여전히 "이 가격이 최종이구나"로 읽는다 —
그 몰에 더 싼 값이 있었을 수 있어 이 결과는 최종이 아니다.

docs/result-states.md 3-2절에 이미 정의해 둔 표기인데(| 일부 확인 못함 | partial |)
몰 칸만 구현하고 결과 열을 빠뜨렸다.

- 결과 열: unconfirmed 가 있으면 '일부 확인 못함'(툴팁에 어느 몰인지). '탐색 성공'(더 싸게 찾음)은
  그대로 우선한다 — 값이 실제로 갱신된 건 사실이므로.
- 재검색 대상: 못 본 몰이 있으면 **완료로 치지 않는다**. 가격이 갱신됐어도 다음 '다시 검색'의
  기본 대상으로 남긴다 — 그게 '다시 시도할 이유가 생긴다'의 실제 동작이다.

tsc 오류 없음. 실데이터로 확인(섬유유연제: 네이버 4,820 matched · 쿠팡 blocked/env_blocked).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:29:15 +09:00
11b29b45e3 [fix] negodata·frontend·backend: 회사별 브랜드 색(primary_color) 기능 전면 제거 — 편집 UI 없이 값만 남아 적용되던 구현 정리(포털·메일·사이드바 색 솔루션 고정, 시드·응답 필드 제거) 2026-08-07 13:45:23 +09:00
4ed23c56b8 [feat] negodata: IMK 요청 일괄 반영 — 조회 게이팅·카드 매칭·부가세 통일 모드·기준가 이원 표기·마스킹 해제·이미지 일괄 업로드·초청메일 브랜딩·반응형 정리 2026-08-07 11:57:06 +09:00
민헌
6ddb2c4504 refactor(postgres-init): LPS 2026-08 마이그레이션 6_·7_ 통합 + 주석 스타일 정리
같은 DB(lps_db)의 같은 시기 추가분이 두 파일로 갈려 있어 순서·누락을 신경 써야 했다.
6_ 하나로 합친다(7_ 삭제) — 실행이 한 번이면 '어디까지 돌렸더라'를 기억할 일이 없다.

- 주석을 3_lps_dbeaver.sql 스타일로 통일: 객체 위 한 줄 설명 + 컬럼 인라인 주석 정렬.
  기존 6_·7_ 의 긴 배경 산문은 걷어냈다 — 배경은 lps/docs/result-states.md 가 소스고,
  이 파일은 '무엇을 만드는가'만 답하면 된다.
- 내용은 그대로: proxy_port(+LRU 인덱스), price_history 신뢰 신호 2·배송 3,
  몰별 확인 상태 sources/partial(+부분 인덱스), 적용 확인 SELECT.

검증: 기존 lps_db 재실행(멱등 — NOTICE 만) + 빈 DB 에 3_ → 6_ 신규 설치 후
price_history 신규 7컬럼·테이블 6종 전부 확인. 테스트 DB 는 정리.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:25:22 +09:00
민헌
95086cb3f6 feat(negodata): 4단계 — 사용자 화면에서 '없음'과 '확인 못함'을 구분
마지막 단계. 차단당해 못 본 몰이 화면에서 '–'(없음)로 보여 사용자가 "쿠팡엔 더 싼 게 없구나"로
오해하던 문제를 끝낸다. 안 본 걸 없다고 말하지 않는다.

사용자 화면은 **셋으로 접는다**(lps/docs/result-states.md 3-2절). 할 수 있는 행동이
'쓴다/다시 시도/넘어간다' 뿐이라, 원인이 달라도 다음 행동이 같으면 같은 표기다:
  matched                            → 가격
  no_match · empty                   → '–'        (확인했고 없었다)
  blocked · env_blocked · unavailable → '확인 못함' (못 봤다)
운영자 화면(lps-admin)은 같은 데이터로 7상태를 그대로 본다 — 목적이 진단이라 접지 않는다.

체인 전체를 이었다:
- postgres-init/alters/2026-08-07-iilp-source-state.sql — item_internet_lowest_prices 에
  sources/partial 추가(멱등, **운영 적용 필요**)
- models.py / lps_sync_crud 읽기 계약 / lps_sync_service 미러링 / LowestPriceEntry 프로토콜
- orval 재생성(ORVAL_INPUT 으로 저장 스펙에서 — 서버 없이). 생성 diff 는 새 필드만.
- PriceUpdateModal: 가격이 없는 몰이 '못 본 몰'이면 '–' 대신 '확인 못함'.

partial 은 LPS 가 판단해 내려준 사실을 그대로 쓴다 — 화면이 '어떤 상태가 확인된 것인가'를
다시 판정하면 상태 정의가 LPS 와 negodata 두 곳으로 흩어진다.

E2E 검증(실 DB 2시나리오): by_mall 은 둘 다 naver 뿐인데 쿠팡 칸이 '확인 못함'(차단) / '–'(0건)
으로 갈린다. tsc 오류 없음(기존 xlsx 미설치 오류는 무관). negodata 97 · lps 292 passed.

> 폴더 관례상 negodata 는 인수인계 대상이나, 사용자 요청으로 이번 건도 예외 적용.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:21:56 +09:00
민헌
d1d1ed6ec5 feat(lps-admin): 3단계 — 몰별 확인 상태를 운영 화면에 노출
2단계에서 저장한 sources/partial 을 운영자가 볼 수 있게 한다. 운영자 목적은 **진단**이라
상태를 접지 않는다 — blocked(IP 회전으로 자동 회복)와 env_blocked(사람이 환경·설정을 고쳐야 함)를
뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.

API
- /v1/lps/products, /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다.
- 이력은 **시점마다** 싣는다. 최신 상태를 과거 시점의 몰별 표 옆에 붙이면 '그때도 막혔던 것처럼'
  보여 오해를 부른다 — 그래서 ProductItem 이 아니라 PricePoint 에 담았다.

화면
- lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에. 미지의 상태가 와도 화면이 깨지지
  않는다(값 그대로 표시 + '모름' 취급). 색은 전부 @theme 토큰 참조(raw hex 금지).
- 상품 목록: partial 이면 '일부 확인 못함' 배지 + 툴팁에 어느 몰인지.
- 몰별 비교 카드 위: 몰별 상태·수집 건수·실패 사유 원문(툴팁). 가격표에 없는 몰이 **왜** 없는지를
  여기서 답한다 — by_mall 은 가격이 있는 몰만 담으므로 그 답이 여기밖에 없다.

검증: ASGI 직접 호출로 두 엔드포인트 응답 확인(한글 사유 포함), tsc 오류 없음.
테스트 3건 추가(목록 노출 / 시점별 상태가 각각 다르게 / 컬럼 추가 이전 옛 행 호환).
전체 292 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:05:31 +09:00
민헌
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
민헌
1e966f7121 docs(lps): 결과 상태 — 운영자와 실사용자를 나눠 접는 원칙 추가
보는 사람이 둘인데 하나로 뭉뚱그리고 있었다.
  lps-admin  운영자 — 목적이 **진단**이다. '왜 그랬나'에 답해야 하므로 7상태를 그대로 보고
             차단 마커·ip_request_no·포트까지 붙인다. 특히 blocked(자동 회복)와
             env_blocked(사람이 고쳐야 함)를 반드시 갈라야 한다 — 개입 여부가 갈린다.
  negodata   실사용자 — 목적이 **행동**이다. 할 수 있는 건 '쓴다/다시 시도/넘어간다' 셋뿐이라,
             원인이 달라도 다음 행동이 같으면 같은 표기로 접는다:
               no_match·empty            → '–'        (둘 다 결론은 '이 몰엔 없다')
               blocked·env_blocked·unavailable → '확인 못함' (행동은 '나중에 다시' 하나뿐)

핵심 원칙: **상태는 하나로 정의·저장하고, 접는 건 표시 단계에서 한다.**
저장을 단순화하면 관리자가 원인을 못 보고, 표시를 상세화하면 사용자가 못 읽는다.
두 화면 모두 price_history 를 읽으므로(admin_service 확인) 데이터는 공유하고 투영만 달리한다.

예외 하나: '일부 확인 못함'(partial)은 사용자에게도 접지 않는다. 이건 행동을 바꾸기 때문이다 —
'이 가격이 최종인가'의 답이 달라진다. 다만 사용자에게 필요한 건 어느 몰이 왜 막혔는지가 아니라
결과가 완전하지 않다는 사실 하나다.

남은 일도 4단계로 갱신(1 플래그 보존 → 2 저장 자리 → 3 admin 표시 · 4 사용자 표시).
3·4 는 같은 데이터를 다르게 접는 것이라 병행 가능.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:11:40 +09:00
민헌
04b697a9eb docs(lps): 검색 결과 상태 정의 — '못 찾음'과 '못 봄'을 가른다
'가격을 못 찾았다' 한 문장에 뜻이 정반대인 상황이 섞여 있다: 그 몰에 정말 없는 것(사실)과
그 몰이 우리를 막아 못 본 것(미확인). 지금 화면은 둘 다 '–' 로 똑같이 보여준다.
구현 전에 용어를 맞추려고 상태를 정의한다.

코드를 확인해 **실제로 구분 가능한 것**만 정의했다:

몰(소스) 단위 7상태 — 경계는 '사실'(no_match/empty) 대 '미확인'(blocked/env_blocked/unavailable).
앞의 둘은 "없다"고 말해도 되고, 뒤의 셋은 말하면 안 된다.

상품 단위 — found / not_found / error + partial 꼬리표. partial 은 독립 상태가 아니라
found·not_found 에 붙는데, 특히 'not_found + partial' 은 결론이 아니다(못 본 몰에 있었을 수 있음).

확인 과정에서 드러난 근본 원인:
- 어댑터는 이미 구분을 **안다** — AdapterError 에 blocked·fatal 이 있고 detect_block 이
  '차단 vs 정상 빈결과'를 판정한다.
- 그런데 **핸들러가 그 플래그를 버린다**: per_source[src] = {"error": f"{...}"} — 문자열만 남는다.
- 게다가 0건도 예외로 온다(return 은 1건 이상일 때만). 즉 empty 와 blocked 가 둘 다
  AdapterError 로 도착하는데 구분 플래그를 버리므로 이후로는 갈라낼 수 없다.

→ 새로 알아낼 정보는 없다. 이미 아는 걸 흘리고 있을 뿐이라, _search_round 한 곳이 출발점이다.
   남은 일을 1(플래그 보존) → 2(price_history 자리) → 3(화면 표기) 순서로 정리했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:46:23 +09:00
민헌
14bcf1481b fix(negodata): 검색 실패를 '변동 없음'으로 표시하던 문구 정정
LPS 가 outcome='error'(모든 소스 차단)로 내려주는 경우가 화면에서 '변동 없음'으로 보였다.
사용자는 '검색은 잘 됐는데 더 싼 게 없구나'로 읽지만, 실제로는 **가격을 확인조차 못 한** 것이다.
뜻이 정반대라 오해가 크다.

원인: success_yn=false 를 전부 notfound 로 묶었고, notfound 와 done 이 같은 문구('변동 없음')를
쓴다. fail_reason 은 이미 API·프론트 타입까지 내려오는데 쓰이지 않고 있었다.

→ fail_reason='error' 면 failed 로 분류한다. 화면은 '탐색 실패'가 되고, 실패 사유가 함께 뜨며,
   재검색 대상(failedIds)에 포함돼 사용자가 다시 시도할 수 있다 — 확인 못 한 건 재시도가 맞다.
   'not_found'(진짜 못 찾음)는 기존대로 '변동 없음'을 유지한다.

LPS 변경 없음(이미 내려오는 값을 쓴 것). ItemState 주석도 실제 의미에 맞게 정정.
tsc: 이 파일 오류 없음(기존 xlsx 미설치 오류는 무관).

> 폴더 관례상 negodata 는 인수인계 대상이나, 이번 건은 사용자 요청으로 예외 적용.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:38:11 +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
민헌
f483839b11 Merge branch 'fix/naver-container-wtm'
네이버 컨테이너 차단 해결 — 원인은 UA 의 플랫폼 토큰(리눅스)이었다.
네이버가 리눅스 데스크톱 Chrome 을 HTTP 405 로 거부한다: Linux UA 0/3 통과 · Mac UA 3/3 통과
(같은 이미지·같은 KR 프록시, IP 3개씩). 405 는 JS 이전 HTTP 계층이라 WebGL·폰트 등 지문은
원인이 아니었다. 리눅스에서만 UA 플랫폼 토큰을 맥으로 치환하며, Chrome 버전은 실제 값을 유지한다.
쿠팡은 잘 통과하므로 건드리지 않았다.
2026-08-06 15:42:49 +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
민헌
3bdff74ceb feat(lps): 네이버 WTM 차단 진단 도구 — 컨테이너에서 '네이버가 보는 우리 브라우저'를 덤프
운영(실 x86 서버) 실측으로 문제 범위가 크게 좁아졌다:
  쿠팡   컨테이너에서 **60건 정상**
  네이버 매 IP 첫 요청부터 wtm_captcha(43~57KB)

즉 '컨테이너가 문제'가 아니라 **네이버 WTM 만 이 컨테이너를 걸러낸다**. kr_host 도 정상이다
(ensure_ports 에 kr.decodo.com 이 찍혔고, 마커가 해외 IP 하드차단 '비정상적인 접근'(2.6KB)이
아니라 wtm_captcha 다 = 한국 IP 는 제대로 나가고 있다). IP·게이트웨이가 아니라 지문 쪽이 남았다.

서버는 Docker 전용이라 '워커만 호스트 실행' 우회를 쓸 수 없다. 그래서 추측으로 이것저것
고치는 대신, **고치기 전에 무엇이 다른지 눈으로 보는** 도구를 먼저 만든다.

diag_naver.py — 컨테이너 안에서 실물과 동일한 스택(patchright + 실제 Chrome + locale/timezone +
kr 프록시)으로 띄워:
- 네이버 WTM 이 볼 수 있는 값 덤프(UA·languages·webdriver·plugins·screen/avail·devicePixelRatio·
  WebGL vendor/renderer·한글 폰트·timeZone …)
- 자동화/가상화로 읽히기 쉬운 항목에 판정과 이유를 붙임
- 실제 msearch 요청 → HTTP·카드 등장 여부·파싱 건수·차단 마커, 실패 시 HTML 저장

--no-cdp   바이트 계측 CDP(Network.enable)를 빼고 A/B — 네이버는 '요청 가로채기 자체가 탐지
           신호'라 리소스 차단을 끈 이력이 있는데, 계측용 CDP 는 그대로 붙고 있다
--no-proxy 프록시 없이 시도 — IP 요인과 지문 요인 분리

재빌드 없이 docker cp + docker exec 로 실행한다(사용법은 파일 상단 docstring).

통과 환경 기준값(맥 직결, 참고용):
  glRenderer=ANGLE(Apple M3 Pro) · plugins=5 · 한글폰트 6종 · screen 2560x1080/avail 2560x1050
  → HTTP 200 · 파싱 6건 · html 1.26MB

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:45:52 +09:00
민헌
b403ca21c9 feat(postgres-init): LPS 2026-08 스키마 추가분 dbeaver 파일 — 운영 워커 기동 실패 해소
운영 워커가 기동 중 크래시 루프에 빠졌다:
  UndefinedTableError: relation "proxy_port" does not exist
  (worker_main.py:336 store.ensure_ports → 기동 시 반드시 만지는 테이블)

3_lps_dbeaver.sql 이후 늘어난 스키마가 운영 DB(172.30.1.36/lps_db)에 반영되지 않은 것이다.
모델은 바뀌었지만 이미 만들어진 DB 는 자동으로 따라오지 않는다.

6_lps_2026-08_dbeaver.sql 신규:
- proxy_port 테이블 + LRU 인덱스(ix_proxy_port_pick)
- price_history 신뢰 신호 2컬럼(final_rating, final_review_count)
- price_history 배송 3컬럼(final_shipping_fee/type/label)
- 끝에 검증 SELECT — 워커가 요구하는 테이블 6종의 존재 여부를 한눈에 확인

전부 IF NOT EXISTS 라 재실행 안전하고, 신규 설치(3 → 6)와 기존 DB 양쪽에 같은 파일을 쓴다.
검증: 기존 lps_db 재실행(NOTICE 만, 오류 없음) + 빈 DB 에 3 → 6 순서 적용 후 컬럼이 모델과 일치.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:25:46 +09:00
17a698a7af Merge branch 'feature/negodata' 2026-08-06 13:13:11 +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
민헌
7b1bcb6d24 docs(lps): README 에 크롤·매칭·IP 로테이션 실제 방식 추가 + 낡은 서술 정정
"어떻게 동작하나"가 워크플로우 그림 한 장뿐이라, 정작 이 시스템에서 어려운 세 가지
(안티봇 뚫는 법·같은 상품 고르는 법·IP 돌리는 법)를 README 만 봐서는 알 수 없었다.
기존 스타일(표·ASCII·비개발자 설명)을 유지해 '🔬 안을 열어보면' 절을 추가한다.

- 소스별 크롤: 네이버(모바일 msearch·WTM)와 쿠팡(Akamai)의 **정반대 전략**을 표로 대조.
  네이버는 리소스 차단을 끄고(요청 가로채기 자체가 탐지 신호) 한국 IP+ko-KR 로케일이 필수,
  쿠팡은 리소스를 막아 대역폭을 줄인다. 스크롤은 횟수가 아니라 '안 늘어남'이 종료 조건인 이유도.
- 같은 상품 판정: 필터 3단계 + AI 판정, '무시할 차이 / 불일치로 볼 차이' 기준표,
  후보를 10건씩 쪼개야 하는 이유(37건 일괄 → 전멸).
- IP 로테이션: 포트=sticky 세션, 게이트웨이 2개, DB 장부(SKIP LOCKED·LRU),
  포트 3상태(임대/휴식/쿨다운), 회전 계기 4종, **태우지 않는 경우**(구조적 차단·서킷브레이커·
  확신 없는 0건)와 예산을 IP 기준으로 세는 이유.

낡은 서술 정정:
- "네이버 쇼핑 API" → 오픈API 는 2026-07-31 종료, 지금은 둘 다 크롤
- 테이블 5종 → 6종(proxy_port), 알림 10룰 → 11룰(fatal_block)
- 요청 예산 "기본 3회" → 쿠팡 3·네이버 10
- config 설명 "배포는 env 주입" → 실제로는 config.local.toml 마운트, env 는 DB 접속점만
- 폴더 구조에 crud/port_lease·profile_slot 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:23 +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
a416668256 [feat] negodata/front: 설정 화면 회사/개발자 분리 + 카드 변수 별칭·미리보기 정비 + 네고율 공통화
- 설정 분리: /settings(최고관리자)는 공급사에게 보이는 브랜딩·안내 문구만, /dev/settings(개발자 신설)는
  협상 동작·데이터 구조를 바꾸는 탭(용어·커스텀 필드 등) — 화면에 보이는 것과 동작을 바꾸는 것을 관리 주체로 가름
- 카드 변수: prev_customer_price(당사 직전가) 칩 추가, 시드/구버전 별칭(anchoring_price 등)은
  툴바 미노출·인식만 지원. SlateRenderer 는 평문에 박힌 미주입 토큰도 카탈로그에 있으면 한글 라벨로 표기
- 견적상세 협상대화의 사용 카드 미리보기를 서식 렌더(renderCardScriptPreview)로 — 마커 원문 노출 제거
- 네고율 차감을 매입가 전용 → 매입가·판매가 공통으로(문구·주석 정리)
2026-08-05 16:00:31 +09:00
54f8f7a6a1 [feat] negodata/front·agent: 카드 제시 가격 변수 선택 + 종결 전용 토글 와일드카드 한정
- 카드 상세 "협상 전술"의 제시 가격을 표시 전용 → 셀렉트로: 기본 '자동(멘트의 마지막 가격 변수)',
  가격 변수가 여럿인 카드만 명시 선택이 의미. 선택지는 멘트에 실제 꽂힌 변수로 제한하고
  멘트 수정으로 선택 변수가 사라지면 자동으로 리셋 — 문구≠계산 어긋남 원천 차단.
  명시 선택 시에만 tactic.offer_variable 저장(agent build_card_spec 이 파싱보다 우선 적용, 기존 경로).
- 종결 전용 토글은 와일드카드 폼에만 노출 — 종결 국면이 와일드카드 목록에서만 카드를 뽑으므로
  협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. 협상카드는 저장 시 항상 false.
- 퍼즈 하네스 100케이스로 확대(시드 고정) — 100/100 불변식 위반 0(타결 94/결렬 6).
- override 우선순위 단위 테스트 추가(자동=마지막 변수, tactic.offer_variable 지정 시 지정 변수).

검증: agent 179 통과 · front tsc+eslint 통과 · 랜덤 협상 100회 완주 위반 0
2026-08-05 15:58:23 +09:00
민헌
042e2d3012 docs(lps): 2026-08-05 세션 기록 — 변경 배경·결정 근거·미해결·다음 할 일
다음 세션이 이어받을 수 있게 남긴다.
- docs/2026-08-05-session-notes.md 신규: 왜 네이버가 크롤로 갔는지, 이번에 정한 것
  (추천순 유지·순위는 상품가·신뢰 신호·AI 배치 10건)과 그 근거, 미해결(컨테이너 크롤 차단),
  다음 할 일 5가지, 작업 시 주의(테스트가 실DB를 TRUNCATE 한다)
- architecture.md: IP 회전을 DB 장부·게이트웨이 2개·회전무효 차단 구분·AI 배치로 갱신
- database.md: proxy_port 테이블 추가(6종), price_history 의 신뢰·배송 컬럼 설명과
  '미검증 오퍼 비율' 쿼리
2026-08-05 15:51:19 +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
c56cf8e3af [feat] agent: 타결선을 목표가 → 타결 상한가로 — 목표가 초과 낙찰 허용(IMK 0803 ②)
목표가를 1원이라도 넘으면 결렬되던 탓에, 기존 단가보다 인하됐는데도 결렬되는 케이스가 있었다
(EST-202607-973E: 기존 17,500 / 목표 16,980 / 최종 17,300). 견적 생성 시 세션에 박제해 두던
done_ceiling_price(목표가×(1+타결상한율), 세팅 기본 +5%)를 협상 엔진이 실제로 읽게 배선했다.

- tactics: settle_ceiling() 신설 — 타결선 판정을 한 곳으로. 박제가 없는 옛 세션·데모는 목표가 폴백
- 카드 제안가 유효조건의 상한도 목표가 → 타결 상한가 (받아줄 수 있는 금액까지는 부를 수 있어야 함)
- _render 가드레일이 목표가 초과 성공을 결렬로 되돌리고 있어 같이 상한 기준으로 교정 —
  타결 판정만 고치면 이 가드에서 다시 뒤집혀, 배선했는데도 결렬로 떨어졌다
- crud/loader/세션 컨텍스트에 done_ceiling_price 적재

검증(목표가 956,580 · 상한 1,004,410): 950,000·1,000,000·1,004,410 타결 / 1,004,500·1,010,000 결렬.
agent 테스트 178건 통과.
2026-08-05 14:10:38 +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
6f5d69b5cb [feat] agent·negodata: 협상카드 전술 데이터화(tactic JSONB·스크립트 파싱) + 발동규칙 정비
IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라
전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱),
문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제.

발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지):
목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 /
필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력)

- agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴),
  진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable),
  에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환
- backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소)
- negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선
- postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화
  (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정)

검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0
(불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
2026-08-05 11:48:14 +09:00
71ef28110b [feat] negodata·agent·negosium: 협상 기준가 회사별 선택(공급가/매입가) + 공급사 포털 안내 회사설정화
어느 가격이 "공급사에 지불하는 단가"인지는 회사마다 달라, 컬럼을 합치는 대신 회사가 고르게 했다.
판정은 settings.features.nego_baseline_field 1순위, 미설정 회사는 price 만 숨겼으면 purchase_price 폴백.
판정식 정본은 negodata/backend/common/nego_baseline.py (agent·negosium backend 가 같은 규칙 미러).

- negodata front: 회사 설정에 협상 기준가 라디오(선택지마다 실제 나갈 문장 미리보기 + 학습 경고),
  '공급사 포털 안내' 탭 신설(협상 유의사항·헬프데스크 연락처), 용어 카탈로그에 target_price·supplier 추가.
  인터넷 최저가는 숨김 대상에서 제외(신규 견적의 유일한 목표가 후보).
  상품 등록 기본값에서 개발용 더미 제거(price 1,000,000·PROD-BAT-###·800,000·대한민국·10 EA·14).
- agent: get_item_price → get_item_baseline (기준가·호칭·회사 용어사전을 한 쿼리로),
  "기존 공급가 대비" 하드코딩을 회사 용어로 치환 + 받침 기준 조사 자동 보정,
  협상 스크립트 4종의 협력사·목표가·배송형태 용어를 {label_*} 토큰화, input_options 도 변수 치환 적용.
- negosium: 협상 화면 기준 단가·배송형태 라벨을 회사 설정 기준으로, 유의사항 본문과 헬프데스크 연락처를
  하드코딩에서 회사 설정으로(미등록 시 영역 숨김). 유의사항의 VAT·배송비 문구 삭제(IMK 0803 ⑥).
- negodata backend: LPS 검색 가격 힌트를 items.price 고정에서 기준가 규칙으로.

검증: 기준가 설정 3 × 숨김 4 = 12조합에서 협상 멘트·포털 표시·LPS 힌트가 전부 일치.
agent 테스트 176건 통과. 보고서 negodata/docs/nego-baseline-verification.md
2026-08-05 11:47:06 +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