Commit Graph

456 Commits

Author SHA1 Message Date
63717f5eb9 [fix] landing: 무대 위 2차 CTA 가 버튼으로 안 읽힘 — stageGhost 대비 확보
GNB "상담 신청" 이 기본 상태에서 버튼으로 인식되지 않았다. 채움이 없고
테두리는 사실상 안 보여서, 남는 건 흰 글자와 화살표뿐 — 옆 nav 링크와
구분될 근거가 없었다.

테두리를 stage-line(#1E2A52)으로 잡은 게 원인이다. 그 색은 무대 바닥(#070E24)
위 카드용인데, 헤더는 방사형 그라데이션의 광원부(#16205A)에 앉는다. 같은 색
가족이라 하필 헤더 자리에서만 선이 배경에 묻힌다.

  수정 전  테두리 대비 1.09:1  (배경과 사실상 동일)
  수정 후  테두리 대비 3.56:1  (WCAG 1.4.11 UI 경계 3:1 통과)

무대색 토큰 대신 흰색 알파로 잡았다 — 무대 어디에 놓여도 같이 성립한다.
옅은 채움(white/10)도 함께 준다. "누를 수 있는 것"은 테두리보다 면이 더
강하게 전달한다.

히어로 "도입 문의" 도 같은 변형이라 함께 해결된다. 1차 CTA(파란 채움)와의
위계는 그대로다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:28:23 +09:00
7f15bc10d1 [fix] landing: 히어로 카피와 입자 밴드 겹침 — 경계를 구조로 강제
버튼 행이 입자 밴드를 파고들었다. 1280x720 에서 34px, 무리 라벨과는 7px.
1440x900 에서만 여유가 있어(45px) 안 보이던 문제다.

원인은 두 값이 서로 다른 기준으로 움직인 것:
  카피 상단 여백  19vh        화면 비율
  카피 높이       351px       고정
  밴드 시작       0.63×높이   화면 비율
카피만 px 고정이라 화면이 짧아지면 밴드가 카피를 덮는다.
0.19H + 351 > 0.63H  →  H < 798px 이면 전부 겹친다.
1280x720, 1366x768 같은 흔한 노트북 높이가 여기 들어간다.

pt-[19vh] 는 위치만 잡을 뿐 경계를 강제하지 못한다. 그래서:
- 카피 영역 높이를 밴드 시작선에 묶었다. 뷰포트와 무관하게 구조적으로 못 겹친다.
- BAND_TOP 을 export 해 캔버스와 카피가 같은 값을 쓴다. 라벨 좌표가 이미 쓰던 방식이다.
- 높이·밴드를 한 식에서 뽑는다. 따로 두면 어긋난다 — 밴드를 63vh 로 두고 높이만
  키웠더니 짧은 화면에서 변수가 실제 밴드보다 위를 가리켰다.
- min-h 바닥 780px. 밴드 위 공간에 헤더(104)+카피(351)+여유(24)=479px 가 들어가려면
  높이 760px 이 하한이라, 정렬만으로는 짧은 화면을 못 구한다. 살짝 스크롤될 뿐이다.
- pt-[104px] 를 md 이상에서도 유지. 빼면 가운데 정렬이 카피를 fixed 헤더 밑으로 민다.

검증(실측, 음수 = 밴드 밖):
  1280x620   +20 → -30,  헤더 여유 +12,  라벨 75px
  1280x720   +34 → -30,  헤더 여유 +12,  라벨 75px
  1440x900   -45 → -68,  헤더 여유 +50,  라벨 122px
  375x812          -57,  헤더 여유 +6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:22:54 +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
d3bcc61609 [chore] landing: Vercel 배포 설정 추가
랜딩은 ssr:false + prerender 인 SSG 라 정적 산출물만 서빙하면 된다.
프레임워크 자동 감지에 맡기면 React Router 를 SSR 로 잡을 수 있어
framework:null 로 명시하고, outputDirectory 를 build/client 로 고정한다.
SPA 리라이트는 Dockerfile.prod 의 nginx try_files 와 동등하게 맞췄다.

.vercel 은 gitignore. 프로젝트·조직 ID 가 들어가는 로컬 링크 파일이다.

배포 메모:
- 저장소가 Gitea 라 Vercel Git 연동(푸시 시 자동 배포)은 안 된다. CLI 수동 배포.
- 새 프로젝트의 첫 배포는 --prod 없이도 production 으로 승격되니,
  preview 를 원하면 --target=preview 를 명시할 것.
- preview 배포는 Vercel 이 x-robots-tag: noindex 를 자동으로 붙인다.
  색인 차단 목적으로 public/robots.txt 를 건드리지 말 것 — 출시 때 되돌리는 걸 잊는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:36:40 +09:00
55e3ab02aa [fix] landing: 섹션 세로 리듬 통일 — 간격을 SectionHeading 이 소유
디자인 시스템이 색·활자만 소유하고 간격은 각 섹션 파일에 흩어져 있어,
같은 관계에 값이 여러 개 생겼다. 스크롤할 때 섹션마다 호흡이 달라지던 원인.

  eyebrow → title      12/20px      → 16px
  title → description  16/20/32px   → 20px (display 티어 32px)
  머리 → 본문          48~96px 5종  → 56px / 80px(md)
  컨트롤 → 본문        56/64px      → 48px
  리드 → CTA           56/48px      → 48px

- SectionHeading 이 리듬을 소유하고 HEADING_GAP·CONTROL_GAP·CTA_GAP·
  DISPLAY_LEAD_GAP 를 노출. 섹션 파일에서 mb-24 같은 값을 직접 쓰지 않는다.
- negotiation-demo·final-cta 가 Section/SectionHeading 을 우회해 머리를 직접
  조판하고 있었다 — 아이브로우 간격이 이 둘만 20px 이던 원인. 프리미티브로 환원.
- SectionHeading 이 eyebrow 없이도 빈 span 을 렌더해 유령 여백이 생기던 버그 수정.
- descriptionClassName 의 text-[17px] 중복 선언 제거. lead 변형이 이미 갖고 있는
  모바일 축소 단계(text-[16px] sm:text-[17px])를 지우고 있었다.
- reinforcement: text-balance 가 쉼표를 넘어 끊던 헤드라인을 <br /> 로 명시.

reinforcement 만 gap="none" 인데, 머리가 그리드 셀 안이라 마진이 상쇄되지 않아
items-center 정렬을 밀기 때문. 간격은 그리드 래퍼가 진다.

검증: tsc --noEmit 통과, 프로덕션 빌드 통과. DOM 실측으로 375/868/1440px
전 구간에서 머리→본문·eyebrow→title·컨트롤→본문 값 일치 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:22:24 +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
민헌
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
민헌
55c18e6a83 fix(lps): 워커 재시작 시 Xvfb 가 stale lock 으로 죽어 모든 크롤이 실패하던 문제
docker compose start/restart 는 컨테이너 파일시스템을 재사용해 지난 실행의
/tmp/.X99-lock 과 /tmp/.X11-unix/X99 가 남는다. Xvfb 가 이걸 '이미 켜진 디스플레이'로
보고 종료하면 DISPLAY 가 없어 headful Chrome 이 못 뜨고, 워커는 살아있는 채로
launch_persistent_context 가 'Missing X server or $DISPLAY' 로 실패한다.
→ CMD 에서 Xvfb 기동 전에 stale lock/소켓을 제거한다.

재현·검증: restart 2회 연속 Xvfb 정상 기동 확인.
2026-08-04 16:30: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
91b8dc77a0 [feat] negodata: 타결 상한(done_ceiling) — 세팅 기본율%·견적 override·세션 박제, 견적생성 3단계 타결/낙찰 섹션 분리·재생성 상속 2026-08-04 08:56:03 +09:00
2bf91d64f5 [refactor] negodata/front: 견적등록 모달 훅 분리 + 시장가 모순 카드 차단(10번), 엑셀 업로드 xlsx 파싱·CP949 폴백(7번) 2026-08-03 09:33:06 +09:00
bae0da42d2 [fix] landing: 히어로 세로 리듬 확보 — 간격 확대·헤드라인 74px
답답함의 원인은 가로 비율이 아니라 세로 간격이었다. 헤드라인 80px 에 대해
헤드라인↔서브 28px, 서브↔CTA 40px 는 너무 촘촘했다.

- 간격 확대: mt-7 → mt-11(44px), mt-10 → mt-14(56px).
- 헤드라인 80 → 74px. 글자 실폭이 뷰포트의 40% (레퍼런스 statworx 는 48%).
- 측정 주의사항을 주석에 남김: h1 은 블록이라 getBoundingClientRect 로 재면
  컨테이너 폭(848px)이 나온다. Range 로 텍스트 노드를 재야 실제 글자 폭이다.
  이걸 헷갈려 한 번 과교정(66px, 36%)했다가 되돌렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:43:16 +09:00
15369a016d [fix] landing: 히어로 여백 확보 — 순환 링을 강화학습 섹션으로 이동
히어로에 헤드라인·서브·CTA 2개·군집 밴드·순환 링이 다 들어가 세로 예산이 포화라
위를 벌리면 아래가 터지는 상태였다. 링을 옮겨 자리를 만든다.

- 순환 링을 강화학습 섹션으로. "협상할수록 좋아진다"를 말하는 자리라
  루프가 의미상 속하는 곳이기도 하다. 텍스트 좌 / 링 우 2단 배치.
- OrbitRing 에 자체 시계 모드 추가 — progressRef·phase 를 안 주면 스스로 돈다.
  덕분에 히어로 캔버스와 무관한 섹션에서도 단독으로 쓸 수 있다.
- 히어로 상단 여백 14vh → 19vh, 밴드를 하단 전체(0.63/0.30)로 확장.
- 군집 라벨 y 계산 버그 수정. 밴드 지역 좌표(0.5)를 화면 좌표로 착각해
  밴드를 옮길 때마다 라벨이 엉뚱한 곳으로 갔다. 이제 행 수·행간에서
  격자 상단을 실제로 계산해 투영한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:42 +09:00
5917942fe3 [fix] landing: 모바일 대응 — 글리프 배율·밴드 리맵·헤더 겹침
- 글리프 크기를 화면 폭에서 파생. 격자 간격은 dx × width 로 폭에 비례하는데
  글리프가 CSS px 고정이라 375px 에서 셀(7.7px)보다 커져(22px) 서로 겹쳐 덩어리가 됐다.
- 좁은 화면(<640px)에서는 밴드를 링 뒤로 내리고 군집 라벨을 접는다.
  모바일은 CTA 가 세로로 쌓여 세로 예산이 부족하고, 375px 에서 3군집을 나란히 두면
  군집당 100px 남짓이라 어차피 판독이 안 된다. 의미는 순환 링이 담는다.
- 히어로 상단 여백을 vh 대신 모바일 고정값으로. 헤더는 고정 높이라
  pt-[7vh](모바일 57px)로는 헤더(약 70px)를 피할 수 없어 헤드라인이 겹쳤다.
- 데스크톱 상단 여백 복원(14vh)과 밴드 재배치(0.575/0.155), 링 290px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:27:02 +09:00
87b896fc2f [fix] landing: 히어로 구도 좌측 쏠림 · 순환 링 정원화 · Playfair self-host
- 군집 좌표를 투영 후 위치에서 역산하도록 변경.
  sx = 0.5 + (x−0.5)·k 라 깊이마다 당겨지는 양이 다르고, 가장 깊은 협력사가
  중앙으로 제일 많이 끌려와 구도가 왼쪽으로 쏠렸다. 이제 화면 19/50/81% 에
  대칭으로 놓인다 — 라벨도 같은 값을 써서 어긋나지 않는다.
- 순환 링을 원근 타원에서 정원으로 교체(orbit-ring). 점선 궤도만 회전하고
  마디·라벨은 고정이다. 눕힌 타원은 깊이는 생기지만 "닫힌 루프"의 완결감이 없다.
- 학습 단계 입자 궤도 제거. 화면을 가로지르는 큰 선이 순환 링과 의미가 겹치고
  시선을 뺏었다. 순환은 링이 맡고 입자는 안쪽으로 흡수만 한다.
- Playfair Display self-host(latin 서브셋 39KB). 구글 폰트 CDN 제거 —
  서드파티 요청이 LCP 를 흔들고 방문자 IP 가 외부로 나간다.
- 히어로 CTA "협상 시연 보기" → "직접 협상해 보기".
  데모는 실제로 동작한다(앵커 이하 낙찰 · 카드 소모 역제안 · 소진 시 개찰 확인).
  수동태 문구는 인터랙티브 기능을 영상처럼 보이게 해 전환을 깎는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:16:28 +09:00
6785c8a6f4 [feat] landing: 랜딩 v2 — 다크 무대 히어로·인터랙티브 협상 데모·디자인 토큰 재정립
레퍼런스(statworx·jitter·infinith) 실측값으로 디자인 시스템을 다시 잡고,
스크롤 재생형 콘솔을 사용자가 직접 협상하는 데모로 교체했다.

디자인 토큰
- 타이포: 굵기·크기·자간을 함께 조정(섹션 제목 44→64px/600→800/자간 -0.042em).
  한글 행간은 1.07 — 라틴 기준 0.95 를 쓰면 네모틀이 꽉 차 줄이 닿는다.
- 모서리: 12가지로 흩어져 있던 값을 card 8px / control 4px / full 셋으로 수렴.
  20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 가장 강한 신호였다.
- 다크 무대 토큰 신설(stage/on-stage/counter) + 배경 그라데이션.
- primary-on-stage(#7C8CFF) 추가 — #0101F3 은 무대 배경 대비 2.0:1 로
  WCAG 최소(4.5:1)의 절반도 안 돼 다크 위에서 읽히지 않았다.
- 영문 디스플레이 서체 Playfair Display self-host(latin 서브셋 39KB).

히어로
- 배경 캔버스가 실제 파이프라인을 그린다: 수집→분류→대조→산정→협상→학습.
  분류 단계는 거래 품목(시트)·사용자·협력사로 갈라지고 라벨이 붙는다.
- z 좌표 + 원근 투영. 군집마다 z 평면을 하나씩 줘 격자는 온전한 채 깊이만 생긴다.
- 순환 링(정원, 점선 궤도 회전). 진행도는 ref 로 공유해 60fps 리렌더를 피한다.
- 카피에 진입 애니메이션을 걸지 않는다 — LCP 요소라 opacity:0 으로 시작하면
  하이드레이션 전까지 첫 화면이 빈다.

협상 데모 (신규)
- 역할 선택(공급사/구매 담당자) → 품목·단가 제시 → 턴 협상 → 낙찰/개찰 → 상담 신청.
- 판정 규칙은 실제 엔진과 동일: 앵커링가 이하 낙찰, 협상 카드 3장, 소진 시 개찰.
- 로직은 lib/negotiation-sim.ts 순수 함수로 분리 — 나중에 agent 서비스 연동 시
  컴포넌트를 건드리지 않는다.

카피 감사
- "봇" 21곳 → "에이전트"(로봇 아이콘 3개 포함). 대리인은 조달 실무의 정당한 역할이다.
- "악역은 봇이", "단가를 깎는 악역" 제거 — 협력사를 적으로 규정하는 프레임은
  같은 페이지 FAQ(공급사 만족도 82%)와 모순이고, 동반성장 평가를 받는
  대기업 구매팀 결재 라인에 올릴 수 없다.
- 번역투 최상급(초정밀·압도적·완벽히·최적의)과 데모 대화문 전면 재작성.

버그 수정
- 헤더가 흰 배경 위에 흰 글자 버튼을 그리던 문제. onStage 를 스크롤 수치로 계산해
  하이드레이션 전 스크롤 복원 시 초기값이 굳었다 → IntersectionObserver 로 교체.
- 버튼 transition-all → transition-colors (variant 교체 시 배경 플리커).
- 폼 입력 14px → 16px. iOS 사파리는 16px 미만 입력에 포커스하면 화면을 확대해
  모바일 폼 이탈을 만든다.

정리
- 대체된 히어로 3종·기존 협상 콘솔 삭제(약 760줄).
- 라벨 아이콘·알약 배지·blur(120px) 글로우 블롭 제거.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:08:37 +09:00
3c3f60c7d4 [chore] 루트 업로드 샘플 CSV 4종 제거(upload-samples/ 와 중복) 2026-07-31 10:26:44 +09:00
bd0f39f131 [feat] negodata·agent: 목표가 네고 적용/미적용 산정근거·와일드카드 1% 10원 반올림·표 xl 전환·QA 체크리스트 2026-07-31 10:22:51 +09:00
775984fe48 [fix] negosium: 협상카드 카운터가 10원 단위 반올림 — 앵커·목표가와 표기 통일 2026-07-30 15:20:01 +09:00
5520e3c2b6 [feat] negodata: 견적 삭제(개발자 전용)·연결 데이터 캐스케이드 soft-delete
- 견적관리 체크박스 선택 + 툴바 '선택 삭제'(상품·협력사 패턴), 상세 시트 단건 '삭제'
- 백엔드 soft_delete 를 연결 협상 세션·대화까지 한 트랜잭션 캐스케이드
- 체크박스·선택삭제·상세삭제 모두 개발자(role 3)에게만 노출
2026-07-30 13:20:42 +09:00
2a004734d8 [feat] negodata·negosium: 엑셀 데이터 내보내기·견적상세 협상카드 연동·드로어 뒤로가기
- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립)
- 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices)
- 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크
- 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용
- 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
2026-07-30 11:03:51 +09:00
35d38e0818 [feat] negosium: 협상 리스트 마감기준 정렬 컨트롤
- SortControl(기본순/마감임박순/여유순) 추가, StatusTabs 우측에 배치
- store setDeadline 배선 — 선택값을 서버 정렬(deadlineToOrder)로 전달
2026-07-30 08:31:16 +09:00
82cca894fc [fix] negosium: 'MD → 구매담당자' 표기 통일
- 챗 봇/안내 라벨(ChatMessage·MDInformation·index.css 메뉴 제목)
- negodata 목표가 산정 문구(pricing) · 온보딩 FAQ/용어(OnboardingGuideModal)
- 견적생성 메모 라벨을 '협력사 안내 메모'로 명확화(협상방에서 협력사에게 보여줄 내용)
2026-07-30 08:31:16 +09:00
9716528ce7 [feat] negosium: 협상 챗 헤더 정리 — 상품명 대표 + 견적번호
- 헤더 대표값을 협상 중인 상품명으로(여러 협상건 중 '이 건' 식별), 펄스 점·'가격 협상 채널' 문구 제거
- 견적번호(qt_number)를 백엔드 chat init → 프론트로 배선해 헤더 칩으로 노출(좁은 모바일은 숨김)
- 모바일 상품 드로어 상단에 구매사 로고+견적번호(BuyerBrandStrip) — 데스크톱 사이드바 로고의 모바일 대체
2026-07-30 08:31:16 +09:00
9dca78dc72 [feat] negosium: 협상완료 부가정보 수취·표시·잠금 + VAT 표기 통일
- 완료 요약 카드(투찰/협상 결과)에 부가정보·의견을 항상 구조로 표시, 미입력 필드는 '-'
- 협상 리스트 부가정보를 완전 읽기전용으로 잠금 — 입력은 완료 챗 마무리에서만
- 어드민 협상상세(현황·챗 탭)에 협력사 의견(custom.opinion) 노출
- 협상 화면 VAT 표기를 회사설정(vat_yn) 기준으로 통일 — hidden이면 라벨 생략
2026-07-30 08:31:16 +09:00
671904c762 Merge commit '30f134836b59843d84b60fa8c0923bd81afefb41' into feature/negodata 2026-07-29 09:57:05 +09:00
6f32b466b9 Merge branch 'design/frontend' into feature/negodata 2026-07-29 09:56:59 +09:00
fe9adcaa26 [fix] negosium/front: 포털 모바일 safe-area 여백 정리 — 헤더 노치(safe-t+min-h)·목록 좌우/하단(safe-x/safe-b)·모달 가장자리 여백(p-4), 채팅 스크롤바 트랙 상단 64px 여백 제거 2026-07-29 09:56:11 +09:00
f554202c58 [fix] anchoring: 앵커링가 1원 단위 내림 → 10원 단위 반올림 (자동 제안가 정돈, IMK #11 케이스) 2026-07-29 09:51:26 +09:00
a56589c6d9 [feat] negosium/협상: 협상 종료폼 통일·의견 수취 — rejectRSP/CM를 단일 RejectForm(사유·희망가·의견)으로 통합해 액션바 렌더, 타결 부가정보에 의견 추가(custom.opinion), 결렬 제출값 파싱해 reject_reason/reject_price 컬럼 저장, 상품상세 필드 라벨 회사설정 연동·협상 단가 VAT별도 표기 2026-07-29 09:51:21 +09:00
b33ae05cd6 [feat] negodata: 견적 목표가·앵커 반올림 + 통계 협상카드 효과 — 목표가=매입가×(1−네고율) 자동입력·산정후보를 낙찰기준탭으로 이동·목표가상한 상시표시 제거, 후보가/앵커 생성가 10원 단위 반올림(신규 세션, schedules 앵커와 동일 공식), 산정모달은 구매담당자 제시가 있어도 후보 전부 노출·MD입력가→구매담당자 제시가, 신규상품 등록에 공급사칸 추가, 상품상세/견적생성 라벨 회사설정 연동, 통계 협상카드 효과(유형별 사용빈도+사용직후 평균 제시가 하락)·와일드카드 1%인하 집계, mono 폰트 폴백 2026-07-29 09:51:03 +09:00
30f134836b [fix/feat] 인터넷 최저가(LPS) — 로딩 무한 버그 수정 · 몰별 최저가 노출 · 탐색/상세 UI 재설계 (#10) 2026-07-28 08:25:14 +00:00
민헌
8fa77af866 [chore] lps: 배포 설정 문서화 + 죽은 env 제거 + 알림 문구 정정
배포서버 쿠팡 크롤 조사(2026-07-28)에서 드러난 '설정한 줄 알았는데 아니었던' 것들 정리.
크롤 동작 자체를 바꾸는 변경은 없다.

- Dockerfile.worker: ENV LPS_CHROME_EXECUTABLE 제거.
  읽는 코드가 없는데 값이 박혀 있어 "컨테이너 Chrome 경로가 설정돼 있다"는 오해를 만들었다.
  경로의 유일한 소스는 [WorkerConfig].chrome_executable 이다(설정 시 --no-sandbox 동반).

- config.local.toml.example: profile_dir 경고 추가.
  컨테이너에서 ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성마다 쿠키가
  날아가고, compose 가 마운트한 lps-profiles 볼륨은 붙어만 있고 아무 일도 하지 않는다.
  로컬·배포서버 양쪽에서 실측(/profiles 는 7/9·7/16 잔재, /app/.profiles 에 9MB 최신).

- config.prod.toml.example 신규: 배포서버 설정 템플릿.
  로컬과 다른 값만 ★ 표시. 헤더에 로드 경로의 함정을 명시했다 —
  docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트하므로
  APP_ENV=local 인데도 내용은 prod 설정이다(파일명만 보면 오해한다).

- worker_main.py: budget_leak 알림이 "ip_request_budget 하향 검토"를 단정하던 것을 수정.
  차단이 ip_req#1 에 몰리면 새 IP 첫 요청부터 막히는 것이라 예산과 무관하다.
  bot_detection.ip_request_no 분포를 보고 처방을 고르도록 문구를 바꿨다.

⚠️ Dockerfile.worker 가 바뀌었으므로 배포 시 lps-worker 재빌드 필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:39:14 +09:00
민헌
89652fac1f [refactor] negodata/front: 인터넷 최저가 상세 시트 재설계 — 실구매가 중심 + 근거 검증
협상 근거로 쓰는 화면이라 "얼마에 살 수 있고, 그게 어디서 나온 값인가"를
한 번에 검증할 수 있게 정보 구조를 다시 짰다.

구조: 결론 → 판단 → 근거
  - 히어로: 합계(상품가+배송비) 32px + 단가 대비 판정을 한 블록에.
    면은 브랜드보다 채도를 낮춘 --accent 표면, 값만 브랜드 컬러(화면에 단 하나).
  - 판매처 비교표: 상품가/배송비/합계를 몰별로. 상품가 최저와 실구매가 최저가
    다를 수 있어 둘 다 표시. 결과 없는 몰도 '미발견' 행으로 남겨 빈손임을 드러낸다.
  - 검색어(찾은 상품명) + 복사 버튼 → 판매처에서 다시 찾아볼 때 붙여넣기용.
  - 가격 추이: 상품가 단일 기준. 합계 기준은 배송비를 아는 회차에만 값이 생겨
    (네이버는 배송비 미제공 — 실측 39/39) 기준을 바꾸면 값의 의미와 표본 수가
    동시에 변한다. 기준선(상품 단가) 추가 — 이 선 위면 사는 게 손해.
  - 수집 이력: 미발견도 노출(전체의 30%가 not_found 인데 그동안 숨겨져 있었다).

데이터 정직성
  - 배송비 미상(전체 69%)을 0원으로 뭉개지 않는다 — 무료배송으로 읽히면 안 된다.
    모르면 히어로 라벨이 '합계' → '상품가'로 바뀌고 그 사실을 문구로 밝힌다.
  - 화면 주인공(합계)과 목표가 산정에 쓰이는 값(상품가)이 다르므로 각주로 명시.
  - 금액 옆 배송 주석은 '+3,500'(연산으로 오해) → '배송비 포함(3,500원)'.

접근성·일관성
  - 대비 전수 계산: --accent 면 위에서 muted-fg(4.16)·amber-700(4.28)이 AA 미달이라
    accent-foreground(9.85)·800 계열(6.0~6.5)로 교체. 히어로는 큰 글씨 기준 통과.
  - rose(파괴적 뉘앙스) 제거 → 브랜드 인디고 한 톤. 장식 아이콘 전부 제거.
  - 타이포 7단계를 상수 T 로 고정(임의 크기 금지).
  - 차트 점 3종 모양을 DOT 상수로 추출해 차트와 범례가 공유 — 범례는 차트 밖이라
    --color-price 가 해석되지 않아 색이 어긋났다. 전역 토큰으로 통일.
  - Y축 눈금 1·2·5×10ⁿ, 기준 변경 시 애니메이션 끔, 커스텀 X축 tick 제거
    (recharts 의 가장자리 앵커 보정을 건너뛰어 점과 라벨이 어긋났다).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:06:27 +09:00
민헌
5e24741395 [refactor] negodata/front: 최저가 탐색 모달 B2B 재설계 + 완료 판정 버그 수정
완료 판정 버그(로딩이 안 멈추던 원인 중 하나)
  폴링이 crawl_end_time 을 문자열로 비교했다. 서버는 마이크로초 6자리(.755705Z),
  JS toISOString() 은 밀리초 3자리(.755Z)라 자릿수가 달라 사전순이 시간순과 어긋난다
  ('.' < 'Z'). 게다가 클라이언트 시각을 서버 타임스탬프의 기준점으로 써서
  브라우저 시계가 조금만 앞서도 어떤 결과도 기준을 넘지 못했다.
  → 요청 직전 서버가 준 최신 수집 시각을 상품별 기준선으로 읽고 epoch ms 로 비교.

UI 재설계 (소비자 앱풍 → B2B 실무 툴)
  - 상태 3분리: confirm | searching | done. 검색 중에 완료 결론을 섞지 않는다
  - 표가 주인공: [선택 | 상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과], 컴팩트 행, tabular-nums
  - 결과 열은 상태어(탐색 성공 / 변동 없음 / 탐색 실패 / 탐색 중)
  - 진행 카드: 표시등 + mm:ss 경과 + 진행바 + N/M, 셀 단위 스켈레톤으로 진행 위치 표시
  - radius 8/6/4, shadow 최소화, CTA 우측 정렬(풀와이드 금지), 강조는 indigo-800 한 톤
  - 완료 후 자동으로 닫지 않는다(결과 확인). 선택 해제는 닫을 때로 미룸

선택 재검색
  체크박스로 대상을 고르고, 검색이 끝나면 갱신되지 않은 행만 자동 선택된다
  (이미 갱신된 상품에 크롤 비용을 다시 쓰지 않도록). 재검색은 force=true 로 나가
  네거티브 캐시를 우회한다.

접근성 (스킬 기준 + 대비 실측)
  - 보조 텍스트 muted-foreground 4.88:1, indigo-800 9.93:1 (기존 1.92:1 FAIL 해소)
  - 상태는 색 단독이 아니라 텍스트/굵기/sr-only 병행
  - role=status aria-live, 탈출구 3중(X·ESC·오버레이), 버튼 높이 통일

api/generated 는 orval 재생성분. resTargetBreakdown 변경은 이전 백엔드 수정과의
스펙 동기화(이번 작업과 무관하지만 재생성으로 함께 정리됨).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:56:00 +09:00