운영 실측으로 범위가 좁혀졌다: 같은 컨테이너에서 **쿠팡은 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>
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>
**낡은 테스트**: 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·프론트 계약은 구현 교체와 무관하다.
기존엔 네이버가 쿠팡 기준 예산(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 를 받도록 갱신.
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-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-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>
- lps-admin 컨테이너화: Vite 정적 빌드 → nginx, /v1·/healthz·/readyz 를
lps-api:9600 으로 동일출처 프록시(빌드 타임 API URL 주입 불필요)
- lps-api/worker: APP_ENV=local 고정 + DB_HOST override(도메인 backend·negodata·agent
와 동일 패턴), config.<env>.toml 마운트 방식 폐기
- server_configs 에 DB 접속 env override(_apply_db_env_override) 복원
- config.dev/prod.toml.example 제거 — 환경 구분 없이 config.local.toml 하나
(prod 서버도 그 서버의 config.local.toml + docker compose up -d)
- run_docker.sh 환경 선택 제거·lps-admin 포함, README·operations 문서 갱신
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
다른 개발자가 어느 환경이든 같은 방식으로 띄울 수 있게 한다.
환경 = APP_ENV 가 고르는 config.<env>.toml 하나(구조 동일, 값만 다름).
- config.docker.toml → config.dev.toml 개명, config.prod.toml.example 신설
(prod 성격 반영: api_keys 필수 표기·debug 로그 금지·웹훅 권장).
- compose 의 lps 서비스 APP_ENV/마운트를 ${APP_ENV:-dev} 로 파라미터화,
API 포트를 ${LPS_API_BIND:-0.0.0.0} 바인드로 노출 제어.
- run_docker.sh(대화형) 신설: 환경 선택 → 설정 파일 검증(없으면 example
복사 제안) → 기동/재시작/중지/로그. prod 는 guard 키 비면 경고 후
확인받고, LPS_API_BIND=127.0.0.1 자동 설정(외부는 리버스프록시 경유).
- 운영 가이드에 환경 개요 표, README 빠른 시작에 Docker 실행 추가.
- 검증: 스크립트 문법·compose(dev/prod) 파싱·APP_ENV=dev 로딩·145 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
설정이 .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>
시크릿 유출 차단(핵심):
- config.local.toml(OpenAI·DECODO·네이버 키)이 COPY . . 로 이미지에
구워지던 문제 — .dockerignore 제외 + 빌드 시 example(플레이스홀더)
복사로 대체. 실값은 compose env 주입(리포 루트 .env, 템플릿 .env.example)
- DECODO_PORT_START/END/SESSION_MINUTES env override 추가 — 포트가
toml(플레이스홀더 0)에만 있으면 자격증명을 넣어도 프록시가 조용히
꺼지는 구멍 봉합
배포 견고화:
- API Dockerfile 에 HEALTHCHECK(/healthz) 추가
- autoheal 컨테이너 추가 — compose restart 는 unhealthy 를 재시작하지
않으므로 라벨(autoheal=true) 기반 자동 재시작 담당
- 이미지 python 3.12→3.14 정렬(로컬 개발·테스트 환경과 일치)
- API 이미지 경량화: requirements-api.txt 분리(크롤 의존성 제거, 330MB)
검증: 양 이미지 빌드 성공, 이미지 내 시크릿·.profiles 부재 확인,
무시크릿 API 이미지 스모크(healthz/readyz/HEALTHCHECK healthy) 통과
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 임시 FE: 총 비용 타일에 AI·DECODO 분해, 크롤 트래픽에 프록시 경유 바이트(과금분)
- config.local.toml.example: DecodoConfig.cost_per_gb 안내
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.
- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
(max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
(detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
설정이 .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>
키는 코드/커밋이 아닌 환경변수로 주입한다(감사: 시크릿 하드코딩 금지).
config.local.toml=비-시크릿 설정, .env=시크릿으로 분리.
- .env.example(템플릿, 커밋) + .env(로컬, 미추적)로 키 슬롯 제공
· NAVER_CLIENT_ID/SECRET(+로테이션 _2..), OPENAI_API_KEY, AZURE_OPENAI_*
- server_configs 가 기동 시 lps/.env 를 load_dotenv 로 환경변수 주입
- requirements: python-dotenv 추가
- README: .env 셋업 단계 안내
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
backend 와 동일한 프레임워크로 lps/ 폴더를 신설한다. 구체적인 도메인
로직(크롤링/오픈API/최저가 산정)은 미정이라 골격만 구성한다.
- FastAPI 부트스트랩(web_main/router) + lifespan·CORS·gzip·로그 미들웨어 + /healthz
- TOML 설정 로더(APP_ENV) + pydantic config + DB env override
- DB 세션 매니저(논리 DB × R/W, service→람다 위임) — 엔진 lazy 라 DB 없이도 부팅
- 공통 응답 규약(gmodel) + ErrorType/DBType/DBWRType
- router→services→crud 계층 컨벤션(services/crud 는 빈 폴더로 자리만)
- 포트 9400(backend 9300·agent 9500 회피), Dockerfile/run_local_server.sh/README
- tests/test_health.py 스모크(healthz, DB 없이 통과)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>