Commit Graph

67 Commits

Author SHA1 Message Date
민헌
8bf34ea346 docs(lps): 프로젝트 문서화 — README + docs/(아키텍처·DB·API·운영)
비개발자/기획자/개발자 누구나 이해하도록 일목요연하게 정리. 길이 분산 위해 분할.

- README.md: 3줄 요약 + 워크플로우 다이어그램 + 주요기능 + 빠른시작 + 문서 목차
- docs/architecture.md: 구성요소·처리 파이프라인·재시도/프록시/봇감지/이력 원리
- docs/database.md: 테이블 4종(job/price_history/search_negative/bot_detection) + 코드값
- docs/api.md: 엔드포인트 요청/응답 예시(curl/Postman), 상태·코드 요약
- docs/operations.md: 실행·로그·DB조회·테스트·문제해결·배포유의

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:27:39 +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
민헌
7491bbecd9 chore(lps): .env 에서 Azure OpenAI 제거 — OpenAI 단일 방식
Azure OpenAI 는 사용하지 않기로 하여 슬롯 제거. AI 는 OPENAI_API_KEY 로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:31:16 +09:00
민헌
c4b8692f3a chore(lps): 시크릿용 .env 도입 — API 키(NAVER/OpenAI/Azure) 주입 경로
키는 코드/커밋이 아닌 환경변수로 주입한다(감사: 시크릿 하드코딩 금지).
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>
2026-07-08 17:14:34 +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
민헌
da9bd7750c feat(lps): 작업 큐 엔진 — PostgreSQL 원자적 claim + lease + dead-letter
레퍼런스의 DB-큐 반면교사를 전부 뒤집는 큐 엔진을 구축한다.
 - 원자적 claim: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING
   → 워커 다수여도 이중 할당 원천 불가(fetch/claim 미분리)
 - 모든 전이는 조건부 CAS(status/worker_id 가드) + RETURNING
 - 복구는 timeout 추측이 아닌 lease 만료 소유권 기반(reaper 회수)
 - 재시도 지수백오프 + max_attempts + dead-letter 를 큐에 내장(스크립트 난립 제거)
 - dedupe_key 부분 유니크로 활성 중복 차단, counts()로 관측(수동 psql 대체)

- enums: JobStatus(PENDING/RUNNING/DONE/DEAD)·JobType(SEARCH/OUTBOX)
- models: job 테이블(무FK·SMALLINT코드·TIMESTAMPTZ, claim/lease/dedupe 인덱스)
- crud/job_crud: JobQueue(enqueue/claim/complete/fail/renew_lease/reap/counts)
- tests: 동시 8워커 이중할당0·lease회수·재시도→dead·소유권가드 등 8건(실 lps_db)

부수: 포트 9400→9600 (negodata 9400 충돌 회피)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:37:32 +09:00
민헌
e27b73c018 feat(lps): 쿠팡 검색 어댑터 — Akamai 우회(Patchright) + 정규화 파서
소스 어댑터 패턴으로 크롤링을 시작한다. 쿠팡은 Akamai Bot Manager 의
JS 행동 챌린지라 curl_cffi 단독 불가 → Patchright(스텔스 Playwright) +
실제 Chrome(channel=chrome)으로 챌린지를 통과하고 selectolax 로 파싱한다.

- contract: SearchAdapter(ABC)·NormalizedProduct·AdapterHealth·AdapterError
- coupang: adapter(브라우저 재사용, 챌린지 1회)·parser(순수)·selectors(외부화, webpack 해시 prefix 매칭)
- 공용: rate_limiter(2~8s 랜덤)·proxy(무프록시 off 인터페이스)·util.parse_price
- 가격 파싱: 단위가격 '(1개당 44,400원)'의 앞 '1' 오인 방지 — '원' 앞 숫자 앵커링
- deps: curl_cffi·selectolax·patchright(nodriver 는 py3.14 버그로 대체)
- tests: 파서 회귀(소형 fixture) — 라이브 2회 검색 + 파서 3/3 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:59:48 +09:00
민헌
11a7be7154 feat(lps): 인터넷 최저가 검색 솔루션 프레임워크 골격 추가
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>
2026-07-08 15:18:27 +09:00