From 8bf34ea346f85b6732c69e3b892def0d76b6b4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 9 Jul 2026 11:27:39 +0900 Subject: [PATCH] =?UTF-8?q?docs(lps):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=AC=B8=EC=84=9C=ED=99=94=20=E2=80=94=20README=20?= =?UTF-8?q?+=20docs/(=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=C2=B7DB=C2=B7API?= =?UTF-8?q?=C2=B7=EC=9A=B4=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비개발자/기획자/개발자 누구나 이해하도록 일목요연하게 정리. 길이 분산 위해 분할. - 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) --- lps/README.md | 155 ++++++++++++++++++++++++++------------- lps/docs/api.md | 134 +++++++++++++++++++++++++++++++++ lps/docs/architecture.md | 80 ++++++++++++++++++++ lps/docs/database.md | 107 +++++++++++++++++++++++++++ lps/docs/operations.md | 109 +++++++++++++++++++++++++++ 5 files changed, 534 insertions(+), 51 deletions(-) create mode 100644 lps/docs/api.md create mode 100644 lps/docs/architecture.md create mode 100644 lps/docs/database.md create mode 100644 lps/docs/operations.md diff --git a/lps/README.md b/lps/README.md index 8d6da00..c314b1e 100644 --- a/lps/README.md +++ b/lps/README.md @@ -1,59 +1,112 @@ -# LPS (Lowest Price Search) +# LPS — 인터넷 최저가 검색 솔루션 -인터넷 최저가를 찾는 솔루션. **프레임워크는 `backend` 와 동일**하게 구성한 골격이며, -구체적인 도메인 로직(크롤링/오픈API 연동/최저가 산정 등)은 아직 정해지지 않았다. +> 상품 정보를 넣으면 **네이버·쿠팡을 뒤져 "같은 상품"의 최저가를 찾아** 돌려주고, 그 가격을 **시간에 따라 기록**해 그래프로 볼 수 있는 시스템입니다. -## 스택 / 아키텍처 (backend 미러링) -- **FastAPI** 앱 (`router/router.py`) + `web_main.py` 부트스트랩 -- **설정**: `config/` — TOML 로더(`config.local.toml`) + pydantic 모델, `APP_ENV`(기본 local) -- **DB**: `common/database/db_session_manager.py` — 논리 DB × Read/Write 엔진, service→람다 위임 패턴 - - 엔진은 lazy 생성이라 **DB 없이도 부팅/healthz 동작**한다. 테이블·crud 가 생기면 그때 실제 접속. -- **공통 응답 규약**: `common/models/gmodel.py` — `Res_WebPacketProtocol.result`(성공/코드/설명) -- **결과 코드**: `common/enums.py` — `ErrorType`, `DBType`, `DBWRType` -- 계층 컨벤션: `router`(컨트롤러) → `services`(비즈니스) → `crud`(DB 접근) +--- + +## 🧭 이게 뭔가요? (비개발자용 3줄 요약) + +1. "맥심 커피 (1박스, 160개입)" 같은 상품 정보를 보내면, +2. 시스템이 **네이버·쿠팡을 실제로 검색**하고, **AI가 "진짜 같은 상품"만 골라** 최저가를 알려줍니다. (빨대·커버 같은 **엉뚱한 액세서리는 걸러냅니다**) +3. 같은 상품을 **여러 번 조회하면 가격 변화가 쌓여서**, 네이버/쿠팡/최종 최저가를 **그래프**로 볼 수 있습니다. + +**왜 유용한가?** 사람이 일일이 검색·비교하지 않아도, 필요한 상품만(조회할 때만) 자동으로 최저가를 찾고 가격 추이를 남깁니다. + +--- + +## ⚙️ 어떻게 동작하나요? (워크플로우) + +``` + [1] 검색 요청 [2] 대기줄(큐) [3] 일꾼(워커)가 처리 + 상품 정보 전송 ─────▶ 순서대로 쌓임 ─────▶ 네이버 + 쿠팡 동시 검색 + (POST /search) (즉시 접수번호 반환) │ + ▼ + [4] 걸러내기 + AI 판정 + 가격 이상치 제거 → "같은 상품"만 선별 + │ + ▼ + [5] 최저가 확정 + 기록 + 네이버/쿠팡/최종 최저가 저장 → 그래프용 이력 적재 + │ + ▼ + [6] 완료(결과 조회 가능) +``` + +**핵심 포인트** +- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림) +- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함) +- **차단 대응**: 쿠팡이 봇으로 감지하면 **다른 IP로 바꿔** 재시도하고, 감지 이력도 기록합니다. + +--- + +## ✨ 주요 기능 + +| 기능 | 설명 | +|------|------| +| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(봇 차단 우회) 동시 검색·병합 | +| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) | +| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 | +| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 최저가를 시계열로 기록 | +| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 | +| 프록시 IP 회전 | 차단 회피용 IP 자동 순환(DECODO) | + +--- + +## 🚀 빠른 시작 + +```bash +cd lps + +# 1) 설정 파일 준비 (DB·API 키 등) +cp config/config.local.toml.example config/config.local.toml # 값 채우기 + +# 2) API 서버 실행 (요청 접수) +./run_local_server.sh # → http://localhost:9600/docs + +# 3) 워커 실행 (실제 검색 수행) — 별도 터미널 +python worker_main.py +``` + +간단 테스트: +```bash +curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \ + -d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}' +``` + +> 자세한 실행/설정은 [운영 가이드](docs/operations.md) 참고. + +--- + +## 📚 문서 + +| 문서 | 대상 | 내용 | +|------|------|------| +| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소, 처리 파이프라인, 재시도·프록시·AI 동작 원리 | +| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 4종 구조와 코드값 | +| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답 예시(Postman/curl) | +| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·로그 보기·DB 조회·문제 해결 | + +--- + +## 📁 폴더 구조 -## 폴더 구조 ``` lps/ -├── web_main.py # 진입점 -├── requirements.txt / Dockerfile / .dockerignore -├── run_local_server.sh # 로컬 실행(대화형), 포트 9600 -├── pytest.ini / conftest.py -├── config/ -│ ├── config_loader.py / config_models.py / server_configs.py -│ └── config.local.toml.example # cp 해서 config.local.toml 로 사용(시크릿, 미커밋) -├── common/ -│ ├── enums.py / logger.py / singleton.py -│ ├── utils/gtime.py -│ ├── models/gmodel.py # 프로토콜 base -│ └── database/{db_session_manager.py, model/models.py(MAIN_BASE)} -├── router/ -│ ├── router.py # app + /healthz (도메인 라우터 미등록) -│ └── v1/validator/dependencies.py# RemoveNoneResponse -├── services/ # (비어있음) 도메인 서비스 추가 위치 -├── crud/ # (비어있음) DB 접근 계층 추가 위치 -└── tests/test_health.py # 스모크 테스트 -``` - -## 로컬 실행 -```bash -cp config/config.local.toml.example config/config.local.toml # 설정+시크릿 전부(포트/DB/API 키) -./run_local_server.sh # → http://localhost:9600/docs -``` -> `config.local.toml` 한 파일에 설정과 시크릿(API 키)을 통합 관리(git 미추적). -> 배포는 이 파일을 마운트하거나, 환경별로 바뀌는 값만 env override(DB_HOST 등). -> 워커는 별도 프로세스: `python worker_main.py` - -## 테스트 -```bash -python -m pytest # tests/ (기본 healthz 스모크) +├── web_main.py # API 서버 진입점 (요청 접수) +├── worker_main.py # 워커 진입점 (실제 검색 수행) +├── run_local_server.sh # 로컬 API 실행 스크립트 +├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋) +├── common/ # 공통(enums, DB 세션, 모델, 로거) +│ └── database/model/models.py # DB 테이블 정의 +├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection) +├── services/ +│ ├── search/ # 소스 어댑터 (coupang, naver) + 프록시·필터 +│ ├── pipeline/ # 필터·이상치·최저가 정렬 +│ └── ai/ # AI 유사도 판정·검색어 생성 (OpenAI) +├── worker/ # 워커 루프·핸들러·알림(NOTIFY) +├── router/v1/lps/ # API 라우터 +└── tests/ # 테스트 ``` ## 포트 -- backend 9300 / negodata 9400 / agent 9500 과 겹치지 않도록 **LPS 는 9600** 사용. - -## 새 도메인 추가 순서 (backend 컨벤션) -1. `common/database/model/models.py` 에 테이블 정의(+ `DBType`) -2. `crud/_crud.py` (ABC 인터페이스 + 구현) -3. `services/_service.py` (비즈니스 로직) -4. `router/v1//{protocol.py, .py}` 작성 후 `router/router.py` 에서 `include_router` +backend 9300 / negodata 9400 / agent 9500 과 겹치지 않게 **LPS는 9600**. diff --git a/lps/docs/api.md b/lps/docs/api.md new file mode 100644 index 0000000..37c7a66 --- /dev/null +++ b/lps/docs/api.md @@ -0,0 +1,134 @@ +# API 사용법 + +[← README로](../README.md) + +- **베이스 URL**(로컬): `http://localhost:9600` +- **Swagger 문서**: `http://localhost:9600/docs` (브라우저에서 바로 테스트 가능) +- 모든 응답에는 공통 **결과 봉투** `result`가 붙습니다: + ```json + "result": { "success": true, "code": 0, "desc": "SUCCESS" } + ``` + (실패 시 `success:false`, `code`/`desc`에 오류 코드) + +--- + +## 1. 검색 요청 — `POST /v1/lps/search` + +상품 리스트를 보내면 상품마다 검색 작업을 큐에 넣고 **접수번호(job_id)**를 즉시 반환합니다. (실제 검색은 뒤에서 진행) + +**요청** +```json +{ + "data": [ + { + "product_code": "T1", // (필수) 상품 식별 코드 — 이력·중복방지 키 + "product_name": "맥심 커피", // (필수) 상품명 + "specification": "1박스, 160개입", // (선택) 규격 — 자유 서술, 형식 무관 + "model": "모카골드", // (선택) 모델명 + "company": "동서식품", // (선택) 제조사/브랜드 + "price": "25000", // (선택) 현재가 — 있으면 가격 범위 필터 기준 + "job_type": "single" // (선택) 요청 유형 → 우선순위 + } + ] +} +``` + +> **specification은 나눌 필요 없이** "1박스, 160개입"처럼 통째로 넣으면 AI가 해석합니다. + +**`job_type` → 우선순위** (낮을수록 먼저): `new`(1) · `single`(2, 기본) · `negowiz`(3) · `batch`(4) + +**응답** +```json +{ + "result": { "success": true, "code": 0, "desc": "SUCCESS" }, + "accepted": 1, + "items": [ { "product_code": "T1", "job_id": "c885...", "duplicated": false } ] +} +``` +- `duplicated: true` → 같은 상품이 이미 처리 대기/진행 중이라 중복 접수 생략(그 경우 `job_id` 없음). + +**curl** +```bash +curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \ + -d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}' +``` + +--- + +## 2. 작업 상태·결과 — `GET /v1/lps/jobs/{job_id}` + +접수번호로 진행 상태와 결과를 조회합니다. (`PENDING` → `RUNNING` → `DONE`) + +**응답 (완료 시)** +```json +{ + "result": { "success": true, "code": 0, "desc": "SUCCESS" }, + "job_id": "c885...", + "status": "DONE", // PENDING / RUNNING / DONE / DEAD + "attempts": 1, + "output": { + "outcome": "found", // found / not_found + "query": "맥심 커피", + "lowest": { "price": 25200, "source": "coupang", "name": "맥심모카골드 ...", "detail_url": "..." }, + "top": [ /* 최저가 상위 N개 */ ], + "sources": { "naver": {"count": 40}, "coupang": {"count": 40} }, + "stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ] + } +} +``` +- `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용). +- 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`. + +```bash +curl localhost:9600/v1/lps/jobs/c885... +``` + +--- + +## 3. 최저가 이력(그래프) — `GET /v1/lps/products/{product_code}/history` + +같은 상품을 여러 번 검색하면 쌓인 스냅샷을 **시각 오름차순**으로 반환합니다. 프론트에서 그래프로 그립니다. + +**쿼리 파라미터**: `limit` (기본 100, 최대 1000) + +**응답** +```json +{ + "result": { "success": true, "code": 0, "desc": "SUCCESS" }, + "product_code": "T1", + "points": [ + { + "triggered_at": "2026-07-09T10:48:47", // X축 + "outcome": "found", + "matched_count": 1, + "naver": 25200, // 네이버 최저가 + "coupang": 24800, // 쿠팡 최저가 + "final": 24800, // 최종 최저가 (Y축) + "final_source": "coupang", + "naver_name": "...", "naver_url": "...", "coupang_name": "...", "coupang_url": "..." + } + ] +} +``` +- `naver`/`coupang`가 `null`인 지점 = 그 시점에 해당 소스엔 그 상품이 없었음(그래프 선 공백). + +```bash +curl "localhost:9600/v1/lps/products/T1/history?limit=100" +``` + +--- + +## 4. 큐 상태 — `GET /v1/lps/queue/stats` + +```json +{ "result": {...}, "counts": { "PENDING": 0, "RUNNING": 1, "DONE": 12, "DEAD": 0 } } +``` + +## 5. 헬스체크 — `GET /healthz` +서버 기동 시각을 반환(살아있는지 확인용). + +--- + +## 상태 코드 요약 +- **작업 상태**: `PENDING`(대기) · `RUNNING`(처리중) · `DONE`(완료) · `DEAD`(실패-확인필요) +- **결과 outcome**: `found`(찾음) · `not_found`(검색했으나 같은 상품 없음) diff --git a/lps/docs/architecture.md b/lps/docs/architecture.md new file mode 100644 index 0000000..a7158d6 --- /dev/null +++ b/lps/docs/architecture.md @@ -0,0 +1,80 @@ +# 아키텍처 — 어떻게 동작하는가 + +[← README로](../README.md) + +## 1. 구성요소 (한눈에) + +| 구성요소 | 파일 | 역할 | +|---------|------|------| +| **API 서버** | `web_main.py` | 검색 요청을 받아 **큐에 적재**만 함(빠르게 응답). 상태·이력 조회 제공 | +| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) | +| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 | +| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) | +| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 | +| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) | + +> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다. + +## 2. 처리 파이프라인 (워커가 하는 일) + +한 건의 검색 작업은 아래 단계를 거칩니다. 각 단계의 통과 건수는 `stages`로 기록됩니다(관측). + +``` +① 네거티브 캐시 확인 + 최근 "없음"으로 확인된 상품이면 → 재검색 생략(비용 절약) + +② 소스 검색 (재정제 루프, 최대 3라운드) + 라운드1: 원본 검색어 → 라운드2: AI 정밀 검색어 → 라운드3: 광역 검색어 + 각 라운드에서 네이버 ∥ 쿠팡 동시 검색 후 병합 + +③ 필터 + - mall 필터 (필요 시 특정 쇼핑몰만/제외) + - 가격 밴드 (요청에 현재가가 있으면 ±범위 밖 제거) + +④ 이상치 제거 (IQR) + 비정상적으로 싸거나 비싼 항목 제거 (오매칭·묶음 등) + +⑤ AI 같은 상품 판정 + 후보 중 "찾는 상품과 동일한 것"만 선별 (액세서리·다른 규격 제외) + → 매칭 있으면: 최저가순 정렬 → 상위 N개 반환 (found) + → 매칭 0건 + 소스 정상: 다음 라운드로 + → 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도) + +⑥ 결과 저장 + 최저가 확정 + 최저가 이력 스냅샷 기록 +``` + +모든 라운드에서 못 찾으면 → **not_found(정상 종료)** + 네거티브 캐시에 기록. + +## 3. 재시도 로직 — "왜 실패했나"에 따라 다르게 + +실패는 성격이 다르므로 **두 종류로 분리**해서 처리합니다. (섞으면 무한 재시도·오작동) + +| 실패 종류 | 예시 | 대응 | +|----------|------|------| +| **기술적 실패** | 네트워크·쿠팡 차단·API 한도·AI 오류 | 잠시 후 재시도(지수 백오프), 여러 번 실패하면 **DEAD**(사람이 확인) | +| **검색어 문제** | "맥심 커피"가 너무 광범위 → 0건 | **검색어를 바꿔** 재시도(정밀→광역), 다 실패하면 **not_found** | +| **진짜 없는 상품** | 실제로 안 파는 상품 | 재시도 무의미 → **not_found로 정상 종료** (에러 아님) | + +**무한 재시도 방지**: 기술적 재시도(횟수 상한)·검색어 재시도(라운드 상한) 둘 다 유한합니다. "못 찾음"은 **실패가 아니라 정상적인 답**으로 처리해 쌓이지 않습니다. + +## 4. 쿠팡 봇 차단 대응 + +- 쿠팡은 **Akamai 봇 차단**(JS 챌린지)이 있어, 일반 HTTP로는 못 뚫습니다 → **실제 Chrome 브라우저**(Patchright)로 통과합니다. +- **프록시 IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단되므로, 일정 시간마다 다른 IP로 바꿉니다. (매 요청마다 바꾸면 오히려 의심받아 **일정 시간 유지 후 회전**) +- **감지 시 즉시 IP 전환**: 차단이 감지되면 새 IP로 바꿔 재시도하고, **감지 이력**(몇 번째 요청에서 걸렸는지)을 기록해 패턴을 분석합니다. +- **대역폭 절약**: 이미지·폰트 등 불필요한 리소스는 받지 않아 프록시 비용을 줄입니다. + +## 5. 최저가 이력 (그래프) + +- **트리거 기반**: 자동 배치로 전 상품을 주기 조회하지 않고, **실제 조회된 상품만** 그 시점에 기록 → 트래픽·비용 절약. +- 검색할 때마다 **네이버 최저가 / 쿠팡 최저가 / 최종 최저가**를 스냅샷으로 남깁니다. +- 그래프: X축 = 조회 시각(불규칙), Y축 = 가격, 3개 선. (한쪽 소스에 그 상품이 없던 시점은 선이 비어있음 — 정상) + +## 6. 설계 원칙 (참고) + +- **PostgreSQL을 큐로 제대로 사용**: 별도 메시지 브로커(Redis 등) 없이, 원자적 작업 할당 + 자동 복구로 유실·중복 없이 처리. +- **소스 어댑터 패턴**: 네이버·쿠팡의 수집 방식 차이를 어댑터 안에 가두고, 코어는 정규화된 결과만 다룸 → 새 쇼핑몰 추가가 쉬움. +- **AI로 매칭**: 상품명 형식이 제각각이라 규칙으로 고정 파싱하지 않고, AI가 "같은 상품인지" 판단. + +더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다. diff --git a/lps/docs/database.md b/lps/docs/database.md new file mode 100644 index 0000000..67f5c04 --- /dev/null +++ b/lps/docs/database.md @@ -0,0 +1,107 @@ +# 데이터베이스 구조 + +[← README로](../README.md) + +- **DB 이름**: `lps_db` (PostgreSQL, negosium_db와 별개) +- **테이블 정의**: `common/database/model/models.py` (SQLAlchemy) — 이 파일이 스키마의 단일 출처 +- **공통 규칙**: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 `TIMESTAMPTZ`(UTC) + +## 테이블 4종 한눈에 + +| 테이블 | 용도 | +|--------|------| +| `job` | 작업 큐 — 검색 요청을 순서대로 보관·처리 | +| `price_history` | 최저가 이력 — 그래프용 시계열 스냅샷 | +| `search_negative` | 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억 | +| `bot_detection` | 봇 감지 이력 — 쿠팡이 차단한 패턴 기록 | + +--- + +## 1. `job` — 작업 큐 + +| 컬럼 | 뜻 | +|------|-----| +| `job_id` | 작업 고유 ID (요청 시 반환되는 접수번호) | +| `job_type` | 작업 종류 (1=검색, 2=외부전송) | +| `status` | 상태 (아래 코드표) | +| `priority` | 우선순위(낮을수록 먼저) | +| `payload` | 요청 내용(상품 정보) JSON | +| `result` | 처리 결과 JSON (최저가·단계·소스별 건수 등) | +| `attempts` / `max_attempts` | 시도 횟수 / 최대 | +| `run_after` | 이 시각 이후 실행(재시도 대기용) | +| `lease_until` / `worker_id` | 점유 만료 시각 / 처리 중인 워커 (죽으면 자동 회수) | +| `last_error` | 마지막 오류 메시지 | +| `created_at` / `updated_at` | 생성/수정 시각 | + +**status 코드값** (`JobStatus`) +| 값 | 이름 | 뜻 | +|----|------|-----| +| 1 | PENDING | 대기 중 | +| 2 | RUNNING | 처리 중 | +| 3 | DONE | 완료 (found/not_found 모두 포함) | +| 4 | DEAD | 재시도 소진 실패 (사람 확인 필요) | + +**job_type 코드값** (`JobType`): 1=SEARCH(검색), 2=OUTBOX(외부전송) + +--- + +## 2. `price_history` — 최저가 이력 (그래프) + +검색할 때마다 1행씩 쌓입니다. 특정 상품의 시계열을 뽑아 그래프로 그립니다. + +| 컬럼 | 뜻 | +|------|-----| +| `product_code` | 상품 식별 키(요청의 product_code) | +| `triggered_at` | 검색 실행 시각 (**그래프 X축**) | +| `outcome` | found / not_found | +| `matched_count` | AI가 "같은 상품"으로 판정한 개수 | +| `naver_lowest` / `naver_name` / `naver_url` | 네이버 최저가 + 상품명/링크 | +| `coupang_lowest` / `coupang_name` / `coupang_url` | 쿠팡 최저가 + 상품명/링크 | +| `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) | +| `final_source` | 최종 최저가가 나온 소스(naver/coupang) | +| `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 | + +> 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상). + +--- + +## 3. `search_negative` — 네거티브 캐시 + +"검색해도 없더라"를 일정 시간(기본 24h) 기억해 **재검색 낭비를 막습니다**. + +| 컬럼 | 뜻 | +|------|-----| +| `key` | 상품 식별 키(보통 product_code) | +| `until` | 이 시각까지 "없음"으로 간주 (지나면 다시 검색 허용) | +| `reason` | 사유 메모 | +| `created_at` | 생성 시각 | + +--- + +## 4. `bot_detection` — 봇 감지 이력 + +쿠팡이 차단(봇 감지)했을 때 기록. "**어떤 IP로 몇 번째 요청에서 걸리나**"를 분석합니다. + +| 컬럼 | 뜻 | +|------|-----| +| `source` | 소스(coupang) | +| `query` | 감지 당시 검색어 | +| `ip_request_no` | 현재 IP(브라우저)로 몇 번째 요청이었나 | +| `proxy_port` | 사용 중이던 프록시 포트(=IP 세션) | +| `elapsed_sec` | 브라우저 실행 후 경과(초) | +| `marker` | 감지 근거(차단 페이지 마커) | +| `headless` / `html_len` | 헤드리스 여부 / 응답 크기 | +| `created_at` | 감지 시각 | + +**분석 예시** +```sql +-- IP당 평균 몇 요청 만에 감지되는지 +SELECT avg(ip_request_no), count(*) FROM bot_detection; +``` + +--- + +## 스키마 생성/관리 + +- 개발·테스트: SQLAlchemy 모델에서 `create_all`로 자동 생성. +- DB 접속(로컬): `psql -h 127.0.0.1 -U postgres -d lps_db` (자세한 쿼리는 [운영 가이드](operations.md)). diff --git a/lps/docs/operations.md b/lps/docs/operations.md new file mode 100644 index 0000000..6cb0cb2 --- /dev/null +++ b/lps/docs/operations.md @@ -0,0 +1,109 @@ +# 운영 가이드 — 실행 · 로그 · DB · 문제 해결 + +[← README로](../README.md) + +## 1. 사전 준비 + +**필요한 것**: Python 3.12+ (로컬은 3.14), PostgreSQL, Google Chrome(쿠팡 크롤링용) + +**설정 파일** (`config/config.local.toml`, git 미커밋) +```bash +cp config/config.local.toml.example config/config.local.toml +``` +채워야 할 값: +| 섹션 | 값 | +|------|-----| +| `[MainDBConfig]` | DB 접속(host/port/id/pw, name=lps_db) | +| `[NaverConfig].keys` | 네이버 쇼핑 API 키(id/secret). 여러 개면 자동 로테이션 | +| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) | +| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) | + +> **한 파일에 설정+시크릿 통합** 관리. 배포 시엔 이 파일을 마운트하거나, 환경별로 바뀌는 값(DB_HOST 등)만 환경변수로 덮어씁니다. + +**DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성. +```bash +createdb -h 127.0.0.1 -U postgres lps_db +psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" +``` + +## 2. 실행 + +**API 서버** (요청 접수) +```bash +./run_local_server.sh # → http://localhost:9600/docs +``` + +**워커** (실제 검색 수행) — 별도 터미널 +```bash +python worker_main.py +# 로그를 실시간으로 보려면: +PYTHONUNBUFFERED=1 python worker_main.py +``` +> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프록시: ON/OFF`, `AI: ON/OFF`가 표시됩니다. + +## 3. 로그 보는 법 (워커 터미널) + +| 로그 | 의미 | +|------|------| +| `[naver] query='...' → N건` | 네이버 검색 결과 수 | +| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 | +| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 | +| `[ai] 검색어 생성 precise=... broad=...` | 0건이라 검색어 재생성 | +| `[coupang][BOT-DETECTED] ...` | **쿠팡 봇 감지** → IP 회전 | +| `[worker-0] done ` / `fail ... → DEAD` | 작업 완료 / 실패 | +| `[reaper] reclaimed N` | 죽은 워커 작업 회수 | + +> 디버그 로그가 안 보이면 `config.local.toml`의 `[LogConfig] log_level = "debug"` 확인. + +## 4. DB 조회 (유용한 쿼리) + +```bash +psql -h 127.0.0.1 -U postgres -d lps_db +``` +```sql +-- 큐 상태 요약 (1=대기 2=처리중 3=완료 4=실패) +SELECT status, count(*) FROM job GROUP BY status; + +-- 최근 작업 결과 +SELECT job_id, status, result->>'outcome' AS outcome, + result->'lowest'->>'price' AS lowest, result->'sources' AS sources +FROM job ORDER BY created_at DESC LIMIT 5; + +-- 특정 상품의 최저가 이력(그래프 원본) +SELECT triggered_at, naver_lowest, coupang_lowest, final_lowest, final_source, outcome +FROM price_history WHERE product_code='T1' ORDER BY triggered_at; + +-- 봇 감지 패턴 (IP당 평균 몇 요청 만에 감지?) +SELECT avg(ip_request_no), count(*) FROM bot_detection; + +-- 네거티브 캐시(없음으로 기록된 상품) +SELECT key, until, reason FROM search_negative ORDER BY created_at DESC; +``` + +## 5. 테스트 + +```bash +python -m pytest +``` +> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요: +> ```bash +> pkill -f worker_main.py +> ``` + +## 6. 문제 해결 + +| 증상 | 원인 / 해결 | +|------|------------| +| 포트 9600 사용 중 | `lsof -ti:9600 \| xargs kill` 후 재실행 | +| 백그라운드 실행 시 로그 안 보임 | `print` 버퍼링 → `PYTHONUNBUFFERED=1` 붙여 실행 | +| `ProcessSingleton ... profile is already in use` | Chrome 프로필 중복 — 워커를 **하나만** 실행(또는 워커별 프로필 분리 필요) | +| 쿠팡 결과 0건 + `blocked=True` | 봇 차단 → 프록시(DECODO) 설정 확인. 감지 이력은 `bot_detection` 참고 | +| AI 매칭이 0건 자주 발생 | 검색어가 모호하거나 스펙이 실제와 다름 → `product_name`/`specification`을 더 정확히 | +| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id | + +## 7. 배포 시 유의 (예정) +- Chrome을 **headless**로(서버엔 화면 없음) — 튜닝 필요 +- Docker 이미지에 **chromium 설치** 필요 +- **프록시(residential)** 사실상 필수 — 클라우드 IP는 쉽게 차단됨 +- 워커 여러 개 띄우면 **워커별 Chrome 프로필 분리** 필요 +- 시크릿(config.local.toml)은 이미지에 굽지 말고 **마운트** 권장