diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..487ecfd --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# docker compose 용 환경변수 템플릿 — 복사해서 사용: cp .env.example .env +# 실제 값(.env)은 커밋하지 않는다(.gitignore). 이미지에는 시크릿이 없으므로(lps 는 example +# config 로 빌드) 아래 값이 없으면 해당 기능이 꺼진 채 뜬다(주석 참고). + +# ── LPS DB (미설정 시 postgres/postgres) ── +LPS_DB_USER=postgres +LPS_DB_PASSWORD=postgres + +# ── LPS API 스케일 (미설정 시 1 / 40 — 단일 프로세스로도 ~1,100 RPS) ── +LPS_API_PROCESS_COUNT=1 # uvicorn 프로세스 수(=사용 코어 수). 커넥션 풀은 예산에서 자동 역산 +LPS_DB_CONNECTION_BUDGET=40 # lps API 커넥션 총예산. 공유 PG=40, 전용 PG(max_conn≈100)=90 + +# ── LPS 워커 시크릿 ── +OPENAI_API_KEY= # 비면 AI 유사도 판정 OFF +NAVER_KEYS= # "id1:secret1,id2:secret2" — 네이버 쇼핑 오픈API 키(여러 개면 로테이션) +DECODO_HOST= # 예: gate.decodo.com — DECODO 4종이 비면 프록시 미사용(직접 연결) +DECODO_USERNAME= +DECODO_PASSWORD= +DECODO_PORT_START=0 # 예: 10001 +DECODO_PORT_END=0 # 예: 10010 +DECODO_COST_PER_GB=0 # 요금($/GB) — 검색 원가 계측용(예: 3.0) diff --git a/.gitignore b/.gitignore index f2c6e78..186a405 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ CLAUDE.md # OS .DS_Store + +# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용 +/Temp.md +/new.md diff --git a/docker-compose.yml b/docker-compose.yml index 0446071..016037d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,3 +112,90 @@ services: options: max-size: "10m" max-file: "5" + + # ── LPS (인터넷 최저가 검색) ────────────────────────────────── + # API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal). + # 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입. + # 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋). + lps-api: + build: + context: ./lps + dockerfile: Dockerfile + container_name: lps-api + environment: + APP_ENV: local + DB_HOST: host.docker.internal # 컨테이너→호스트 DB (example toml 의 127.0.0.1 override) + DB_USER: ${LPS_DB_USER:-postgres} + DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres} + PYTHONUNBUFFERED: "1" + # 멀티코어: PROCESS_COUNT(uvicorn 워커=코어수)를 올리면 커넥션 풀은 자동 산정된다. + # (pool+overflow)×2엔진×PROCESS_COUNT ≤ DB_CONNECTION_BUDGET 를 config 가 스스로 보장. + # 값은 .env 에서 서버별로 조정(compose 수정 불필요). API 병목은 드묾 — 기본 1이면 충분, + # 부하테스트/대량 폴링 대비 시에만 코어 수만큼 상향(예: 4). + PROCESS_COUNT: ${LPS_API_PROCESS_COUNT:-1} + DB_CONNECTION_BUDGET: ${LPS_DB_CONNECTION_BUDGET:-40} # 전용 PG(max_connections≈100)면 90 근처로 상향 + ports: + - "9600:9600" + extra_hosts: + - "host.docker.internal:host-gateway" + labels: + autoheal: "true" # HEALTHCHECK 실패 시 autoheal 이 재시작 + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + + lps-worker: + build: + context: ./lps + dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨) + container_name: lps-worker + environment: + APP_ENV: local + DB_HOST: host.docker.internal + DB_USER: ${LPS_DB_USER:-postgres} + DB_PASSWORD: ${LPS_DB_PASSWORD:-postgres} + PYTHONUNBUFFERED: "1" + WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N) + LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피) + # LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검) + # LPS_JOB_DEADLINE_SEC: "300" # 잡 1건 처리 상한(행 방어) — 기본 300s + # ── 시크릿 주입(이미지엔 없음 — 필수). 리포 루트 .env 에 값 채움(.env.example 참고) ── + OPENAI_API_KEY: ${OPENAI_API_KEY:-} # 비면 AI 판정 OFF + NAVER_KEYS: ${NAVER_KEYS:-} # "id1:secret1,id2:secret2" — 비면 네이버 검색 실패 + DECODO_HOST: ${DECODO_HOST:-} # DECODO 4종 비면 프록시 미사용(직접 연결) + DECODO_USERNAME: ${DECODO_USERNAME:-} + DECODO_PASSWORD: ${DECODO_PASSWORD:-} + DECODO_PORT_START: ${DECODO_PORT_START:-0} + DECODO_PORT_END: ${DECODO_PORT_END:-0} + DECODO_COST_PER_GB: ${DECODO_COST_PER_GB:-0} + volumes: + - lps-profiles:/profiles # Chrome 프로필(쿠키) 영속 + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시 + stop_grace_period: 75s # graceful 종료 유예(LPS_SHUTDOWN_GRACE_SEC=60 + 정리 여유) — 기본 10s 면 하던 잡 마무리 전에 SIGKILL + labels: + autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작 + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + + # HEALTHCHECK 실패 컨테이너 자동 재시작 — compose 의 restart 는 '프로세스 종료'만 다루고 + # unhealthy 는 표시만 하므로, autoheal 라벨 붙은 컨테이너(lps-api/lps-worker)를 감시해 재시작한다. + # docker.sock 마운트 = 도커 제어 권한이므로 신뢰 환경에서만 사용. + autoheal: + image: willfarrell/autoheal:latest + container_name: autoheal + environment: + AUTOHEAL_CONTAINER_LABEL: autoheal + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + +volumes: + lps-profiles: diff --git a/lps-temp-fe/index.html b/lps-temp-fe/index.html new file mode 100644 index 0000000..fb07459 --- /dev/null +++ b/lps-temp-fe/index.html @@ -0,0 +1,672 @@ + + + + + +LPS · 최저가 검색 콘솔 (임시 FE) + + + + +
+
+
+ +
LPS 최저가 검색 콘솔 + 임시 프론트엔드 · API 통신 확인용 +
+
+
+
+ PENDING – + RUNNING – + DONE – + DEAD – +
+
확인 중…
+
+
+ +
+ +
+
+
1

검색 요청

POST /v1/lps/search
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+ +
+

API 설정

Base URL
+
+
+ + +
+

+ 이 페이지는 :5173에서 서빙되어야 CORS가 통과합니다 (serve.sh 참고). +

+
+
+
+ + +
+ +
+
2

작업 추적 & 상품 정보

GET /v1/lps/jobs/{id}
+
+
+
📦
+ 왼쪽에서 검색을 요청하면 여기에서
진행 상태와 최저가 결과가 실시간으로 표시됩니다. +
+
+
+ + +
+
3

최저가 이력 그래프

GET /v1/lps/products/{code}/history
+
+
+
+ + +
+ +
+
+
📈
상품 코드를 입력하고 이력을 불러오세요.
+
+
+
+
+
+ +
+ + + + diff --git a/lps-temp-fe/serve.sh b/lps-temp-fe/serve.sh new file mode 100755 index 0000000..81ac4d6 --- /dev/null +++ b/lps-temp-fe/serve.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# LPS 임시 프론트엔드 서버 (대화형). +# CORS 때문에 반드시 :5173 에서 서빙해야 lps API(:9600) 호출이 통과한다. +# (lps/config.local.toml 의 cors_origins = http://localhost:5173) +# +set -euo pipefail +cd "$(dirname "$0")" + +PORT=5173 +echo "── LPS 임시 FE 서버 ──" +echo " API(:9600) 는 별도로 실행돼 있어야 합니다 (lps/run_local_server.sh + worker_main.py)." +echo "" +read -rp "포트 [${PORT}]: " p +PORT="${p:-$PORT}" + +# 포트 정리 +if lsof -ti:"$PORT" >/dev/null 2>&1; then + echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료" + lsof -ti:"$PORT" | xargs kill 2>/dev/null || true + sleep 1 +fi + +echo "[run] http://localhost:${PORT} (Ctrl+C 로 종료)" +# 브라우저 자동 오픈 (mac) +( sleep 1; command -v open >/dev/null && open "http://localhost:${PORT}" ) & +exec python3 -m http.server "$PORT" --bind 127.0.0.1 diff --git a/lps/.dockerignore b/lps/.dockerignore new file mode 100644 index 0000000..c1ff510 --- /dev/null +++ b/lps/.dockerignore @@ -0,0 +1,15 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.git/ +tests/ +loadtest/ +*.md +# Chrome 프로필(쿠키·cf_clearance 세션) — 이미지에 구우면 세션 유출 + 149MB 비대. +# 컨테이너는 빈 프로필로 시작해 웜업으로 쿠키를 만들고 volume(/profiles)에 영속한다. +.profiles/ +# 시크릿(OpenAI·DECODO·네이버 키) — 이미지에 굽지 않는다. Dockerfile 이 example 을 +# 복사해 넣고, 실값은 compose env 로 주입(server_configs 의 env override). +config/config.local.toml diff --git a/lps/.gitignore b/lps/.gitignore new file mode 100644 index 0000000..6aaa448 --- /dev/null +++ b/lps/.gitignore @@ -0,0 +1,5 @@ +# Chrome 프로필(쿠키·cf_clearance 세션) — 로컬 워커 영속 볼륨. 절대 커밋하지 않는다. +.profiles/ + +# 로컬 워커 하트비트/부하 테스트 로그(기본은 /tmp 지만 상대경로로 뜰 때 대비) +lps_worker_heartbeat diff --git a/lps/Dockerfile b/lps/Dockerfile new file mode 100644 index 0000000..8aee42e --- /dev/null +++ b/lps/Dockerfile @@ -0,0 +1,27 @@ +# LPS API 서버 이미지 — 요청 접수/조회만(브라우저 불필요, lean). +# 크롤은 별도 워커 이미지(Dockerfile.worker, Chromium+Xvfb)가 담당한다. +# 시크릿은 이미지에 굽지 않는다 — config 는 example(플레이스홀더)로 대체되고, +# 실제 값은 compose 의 env(DB_USER/DB_PASSWORD 등)로 주입된다(server_configs override). +FROM python:3.14-slim + +WORKDIR /app + +# 의존성 먼저 설치 (레이어 캐시 활용) — API 전용 경량 세트 +COPY requirements-api.txt . +RUN pip install --no-cache-dir -r requirements-api.txt + +COPY . . + +# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체. +# 실값은 env 주입: DB_HOST/DB_USER/DB_PASSWORD/DB_NAME, PROCESS_COUNT, DB_CONNECTION_BUDGET … +RUN cp config/config.local.toml.example config/config.local.toml + +# 항상 APP_ENV=local 로 실행 → config.local.toml(=example 사본) + env override. +ENV APP_ENV=local + +EXPOSE 9600 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9600/healthz', timeout=4).status==200 else 1)" + +CMD ["python", "web_main.py"] diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker new file mode 100644 index 0000000..b8813e6 --- /dev/null +++ b/lps/Dockerfile.worker @@ -0,0 +1,38 @@ +# LPS 워커 이미지 — 크롤용 **헤드풀 Chromium + 가상 디스플레이(Xvfb)**. +# headless Chrome 은 Akamai(쿠팡)·Cloudflare Turnstile(G마켓/옥션)에 탐지돼 통과 못 함(실측 확인). +# → 컨테이너에선 Xvfb(가상 프레임버퍼)로 headful Chromium 을 실행한다. +# +# 빌드/실행: +# docker compose build lps-worker && docker compose up -d lps-worker +# 스텔스 참고: 로컬 Mac 은 실제 Chrome(channel=chrome), 컨테이너는 시스템 chromium(executable_path). +# 프로덕션에서 stealth 가 부족하면 amd64 이미지에 google-chrome-stable 설치 후 LPS_CHROME_CHANNEL=chrome 로 전환. +FROM python:3.14-slim + +# chromium(런타임 의존성 apt 가 자동 해결) + Xvfb + 한글 폰트 +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium xvfb xauth fonts-nanum ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체. +# 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. +RUN cp config/config.local.toml.example config/config.local.toml + +ENV APP_ENV=local \ + PYTHONUNBUFFERED=1 \ + LPS_CHROME_EXECUTABLE=/usr/bin/chromium \ + DISPLAY=:99 \ + LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat + +# 하트비트 신선도(<120s)로 행/좀비 워커 감지. start-period 는 웜업(브라우저 기동) 여유. +HEALTHCHECK --interval=30s --timeout=8s --start-period=120s --retries=3 \ + CMD python -c "import os,time,sys; p=os.environ['LPS_HEARTBEAT_FILE']; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p)<120 else 1)" + +# Xvfb(가상 디스플레이)를 백그라운드로 띄우고 python 을 exec 로 승계 실행. +# → 헤드풀 Chromium 이 :99 에 뜨고, 워커 로그는 그대로 docker logs 로 나온다(xvfb-run 은 로그를 삼킴). +CMD ["bash", "-c", "Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; exec python worker_main.py"] diff --git a/lps/README.md b/lps/README.md new file mode 100644 index 0000000..9008ff4 --- /dev/null +++ b/lps/README.md @@ -0,0 +1,132 @@ +# LPS — 인터넷 최저가 검색 솔루션 + +> 상품 정보를 넣으면 **네이버·쿠팡을 뒤져 "같은 상품"의 최저가를 찾아** 돌려주고, 그 가격을 **시간에 따라 기록**해 그래프로 볼 수 있는 시스템입니다. + +--- + +## 🧭 이게 뭔가요? (비개발자용 3줄 요약) + +1. "맥심 커피 (1박스, 160개입)" 같은 상품 정보를 보내면, +2. 시스템이 **네이버·쿠팡을 실제로 검색**하고, **AI가 "진짜 같은 상품"만 골라** 최저가를 알려줍니다. (빨대·커버 같은 **엉뚱한 액세서리는 걸러냅니다**) +3. 같은 상품을 **여러 번 조회하면 가격 변화가 쌓여서**, 네이버/쿠팡/최종 최저가를 **그래프**로 볼 수 있습니다. + +**왜 유용한가?** 사람이 일일이 검색·비교하지 않아도, 필요한 상품만(조회할 때만) 자동으로 최저가를 찾고 가격 추이를 남깁니다. + +--- + +## ⚙️ 어떻게 동작하나요? (워크플로우) + +``` + [1] 검색 요청 [2] 대기줄(큐) [3] 일꾼(워커)가 처리 + 상품 정보 전송 ─────▶ 순서대로 쌓임 ─────▶ 네이버 + 쿠팡 동시 검색 + (POST /search) (즉시 접수번호 반환) │ + ▼ + [4] 걸러내기 + AI 판정 + 가격 이상치 제거 → "같은 상품"만 선별 + │ + ▼ + [4-1] 오픈마켓 폴백 (옵션 · 기본 꺼짐) + 네이버가 못 덮은 몰(G마켓·옥션·11번가)만 크롤 + │ + ▼ + [5] 최저가 확정 + 기록 + 네이버/쿠팡/최종 + 몰별 최저가 저장 → 이력 적재 + │ + ▼ + [6] 완료(결과 + 검색 원가 조회 가능) +``` + +**핵심 포인트** +- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림) +- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함) +- **차단 대응**: 쿠팡(Akamai)·G마켓(Cloudflare 사람확인) 등이 봇으로 감지하면 **다른 IP로 바꿔** 재시도하고, 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다. +- **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다. + +--- + +## ✨ 주요 기능 + +| 기능 | 설명 | +|------|------| +| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 | +| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`LPS_FALLBACKS`, [배경](docs/decision-openmarket-crawler.md)) | +| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) | +| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 | +| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 | +| 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 | +| 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) | +| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 | +| 프록시 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) 워커 실행 (실제 검색 수행) — 별도 터미널 +./run_local_worker.sh # 대화형: 동시성·프로필 선택 (또는 python worker_main.py) + +# (선택) 부하 테스트 GUI — Locust 웹 UI(:8089) +./run_loadtest_gui.sh # 브라우저에서 users/spawn 조절하며 RPS/지연 관측 +``` + +간단 테스트: +```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)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 | +| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 4종 구조와 코드값(+by_mall) | +| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 | +| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 | +| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 | + +--- + +## 📁 폴더 구조 + +``` +lps/ +├── web_main.py # API 서버 진입점 (요청 접수) +├── worker_main.py # 워커 진입점 (검색+웜업+유휴정리+ops모니터) +├── Dockerfile # API 이미지(lean) · Dockerfile.worker # 워커(Chromium+Xvfb) +├── run_local_server.sh # 로컬 API 실행 (대화형) +├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필) +├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형) +├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋; 배포는 env 주입) +├── common/ # 공통(enums, DB 세션, 모델, 로거) +│ └── database/model/models.py # DB 테이블 정의 +├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계) +├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection) +├── services/ +│ ├── search/ # 소스 어댑터 (coupang, naver, esm=G마켓·옥션, st11=11번가) +│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측) +│ │ ├── proxy.py # DECODO(IP 회전·프리플라이트) +│ │ └── card_parser.py # 오픈마켓 공용 카드 파서 +│ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해) +│ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI) +│ └── metrics.py # 검색 원가 계측(AI/DECODO 비용·시간) +├── worker/ # 워커 루프·핸들러(폴백·데드라인)·알림(NOTIFY) +├── router/v1/lps/ # API 라우터 +└── tests/ # 테스트 +``` + +## 포트 +backend 9300 / negodata 9400 / agent 9500 과 겹치지 않게 **LPS는 9600**. diff --git a/lps/common/database/db_session_manager.py b/lps/common/database/db_session_manager.py new file mode 100644 index 0000000..82c18eb --- /dev/null +++ b/lps/common/database/db_session_manager.py @@ -0,0 +1,206 @@ +from asyncio import current_task + +from sqlalchemy.orm import sessionmaker +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_scoped_session +from sqlalchemy.util._collections import immutabledict + +from common.database.model.models import MAIN_BASE +from common.enums import DBType, DBWRType, ErrorType +from common.logger import LOG +from common.singleton import Singleton +from config.server_configs import main_db_config + + +class DBSessionManager(Singleton): + """DB 세션/엔진 관리자 (싱글톤). + + 핵심 패턴 + - DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다. + => 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다. + - 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다. + execute_lambda : 단일 쿼리 (주로 조회) + execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit + 세션 open/close 와 commit/rollback 은 매니저가 책임진다. + - 엔진 생성은 lazy 하다(create_async_engine 은 실제 커넥션을 맺지 않음). DB 없이도 import/부팅 가능. + """ + + def __init__(self): + if DBSessionManager.is_init(): + LOG.e_no_callstack("already init DBSessionManager") + return + DBSessionManager.set_init() + + self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"} + # 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다. + self.__engines = [] + # 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다. + self.__db_type_map = { + DBType.MAIN.value: main_db_config, + } + + # Write 엔진 맵 + self.__write_session = { + DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_WRITE.value), + } + # Read 엔진 맵 + self.__read_session = { + DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value), + } + + def create_engine(self, db_type: int, db_wr_type: int): + db_config = self.__db_type_map.get(db_type) + if not db_config: + raise ValueError("Invalid database type") + + if db_wr_type == DBWRType.DB_READ.value: + pw = (":" + db_config.read_pw) if len(db_config.read_pw) > 0 else "" + db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.read_id}{pw}@{db_config.read_host}:{db_config.read_port}/{db_config.name}" + LOG.i(f"Read DB create engine url : {db_url}") + else: + pw = (":" + db_config.write_pw) if len(db_config.write_pw) > 0 else "" + db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}" + LOG.i(f"Write DB create engine url : {db_url}") + + # SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수. sslmode 가 설정되면 asyncpg 에 전달. + connect_args = {} + sslmode = (getattr(db_config, "sslmode", "") or "").lower() + if sslmode and sslmode != "disable": + connect_args["ssl"] = sslmode + + engine = create_async_engine( + db_url, + echo=db_config.show_log, + pool_size=db_config.pool_size, + max_overflow=db_config.max_overflow, + pool_pre_ping=True, + pool_recycle=600, + connect_args=connect_args, + ) + self.__engines.append(engine) + scoped_session = async_scoped_session( + sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False), + scopefunc=current_task, + ) + return scoped_session + + async def dispose_all(self): + """모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다. + 호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다. + """ + for engine in self.__engines: + await engine.dispose() + + # ---- 세션 lifecycle ------------------------------------------------- + async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession: + if db_wr_type == DBWRType.DB_WRITE.value: + return self.__write_session[db_type]() + return self.__read_session[db_type]() + + async def end_session(self, db_type: int, db_wr_type: int): + if db_wr_type == DBWRType.DB_WRITE.value: + await self.__write_session[db_type].remove() + else: + await self.__read_session[db_type].remove() + + # ---- 저수준 DB 연산 (crud 에서 호출) -------------------------------- + async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType: + try: + await db.commit() + return ErrorType.SUCCESS + except IntegrityError as ex: + await db.rollback() + LOG.e_no_callstack(f"duplicated. {ex}") + return ErrorType.DB_ALREADY_SAME_KEY + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def insert(self, db: AsyncSession, obj, err_msg="DB Failed", raise_error=True) -> ErrorType: + try: + if isinstance(obj, MAIN_BASE): + db.add(obj) + elif isinstance(obj, list): + db.add_all(obj) + else: + raise RuntimeError("DO NOT USE QUERY IN DBJOB") + return ErrorType.SUCCESS + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def add(self, db: AsyncSession, query, err_msg="DB Operation Failed", raise_error=True) -> ErrorType: + """update/delete 등 비-select 쿼리 실행.""" + try: + if hasattr(query, "column_descriptions"): + raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB") + await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"})) + return ErrorType.SUCCESS + except IntegrityError as ex: + await db.rollback() + err_type = ErrorType.DB_ALREADY_SAME_KEY + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + return err_type + except Exception as ex: + await db.rollback() + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type + + async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]: + """select 쿼리 실행 후 결과 리스트 반환.""" + try: + if not hasattr(query, "column_descriptions"): + raise RuntimeError("DO NOT USE NON-SELECT QUERY IN DBJOB") + res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"})) + return ErrorType.SUCCESS, res.scalars().fetchall() if 1 == len(query.column_descriptions) else res.all() + except Exception as ex: + err_type = ErrorType.DB_RUN_FAILED + LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}") + if raise_error: + raise RuntimeError(err_type.name, err_msg) + return err_type, [] + + # ---- 람다 실행 진입점 (service 에서 호출) --------------------------- + async def execute_lambda(self, db_type: int, db_wr_type: int, func): + """단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환.""" + s = await self.start_session(db_type, db_wr_type) + try: + return await func(s) + finally: + await self.end_session(db_type, db_wr_type) + + async def execute_lambda_run(self, db_type_list: list[int], func_list: list): + """동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit. + 하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다. + """ + temp_list = list(set(db_type_list)) + if len(temp_list) != 1: + return ErrorType.DB_INVALID_TYPE + + db_type = temp_list[0] + s = await self.start_session(db_type, DBWRType.DB_WRITE.value) + try: + for func in func_list: + err_type = await func(s) + if err_type != ErrorType.SUCCESS: + return err_type + return await self.run(s) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + finally: + await self.end_session(db_type, DBWRType.DB_WRITE.value) + + +DB_SESSION_MNG = DBSessionManager() diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py new file mode 100644 index 0000000..78c0bce --- /dev/null +++ b/lps/common/database/model/models.py @@ -0,0 +1,127 @@ +from sqlalchemy import Boolean, Column, Index, Integer, SmallInteger, String, Text, DateTime +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import declarative_base +from sqlalchemy.sql import text + +from common.enums import DBType + +# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. +MAIN_BASE = declarative_base() + + +class job(MAIN_BASE): + """작업 큐. PostgreSQL 을 '제대로' 큐로 쓴다 — 원자적 CAS claim + lease 소유권 + dead-letter. + 코드값(status/type)은 SMALLINT 정수 코드(common.enums 매핑), 시각은 전 구간 TIMESTAMPTZ, 무 FK. + """ + + @staticmethod + def DBType(): + return DBType.MAIN.value + + __tablename__ = "job" + + job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + job_type = Column(SmallInteger, nullable=False) # JobType + status = Column(SmallInteger, nullable=False, server_default=text("1")) # JobStatus (1=PENDING) + priority = Column(SmallInteger, nullable=False, server_default=text("100")) # 낮을수록 우선 + payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력 + result = Column(JSONB, nullable=True) # 잡 출력(완료 시) + dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크) + attempts = Column(SmallInteger, nullable=False, server_default=text("0")) # 시도 횟수(claim 시 +1) + max_attempts = Column(SmallInteger, nullable=False, server_default=text("3")) + run_after = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 이 시각 이후에만 claim(백오프) + lease_until = Column(DateTime(timezone=True), nullable=True) # 소유권 임대 만료(reaper 회수 기준) + worker_id = Column(String(80), nullable=True) # 현재 점유 워커 + run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각(할당시각과 분리) + last_error = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"), onupdate=text("now()")) + + __table_args__ = ( + # claim 정렬/필터용: PENDING 중 run_after 지난 것을 priority·생성순으로 + Index("ix_job_claim", "status", "run_after", "priority", "created_at"), + # reaper: 만료된 RUNNING lease 회수용 + Index("ix_job_lease", "status", "lease_until"), + # 활성 중복 방지: 같은 dedupe_key 는 PENDING/RUNNING 중 하나만 존재 가능 + Index( + "uq_job_dedupe_active", + "dedupe_key", + unique=True, + postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"), + ), + ) + + +class search_negative(MAIN_BASE): + """네거티브 캐시 — '검색해도 없더라'를 TTL 동안 기억해 재검색 낭비를 막는다. + until 이 지나면 자동 무효(재도전 허용 — 나중에 입고될 수 있으므로).""" + + @staticmethod + def DBType(): + return DBType.MAIN.value + + __tablename__ = "search_negative" + + key = Column(String(300), primary_key=True) # 보통 product_code(없으면 query) + until = Column(DateTime(timezone=True), nullable=False) # 이 시각까지 not_found 로 간주 + reason = Column(String(200), nullable=True) # 종료 사유 메모(관측) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) + + +class price_history(MAIN_BASE): + """상품별 최저가 스냅샷(트리거 기반). 네이버/쿠팡/최종 최저가를 검색 시점마다 적재해 + 시계열 그래프(X=triggered_at, Y=가격, 3개 선)로 본다. 배치 아님 — 조회된 상품만 기록.""" + + @staticmethod + def DBType(): + return DBType.MAIN.value + + __tablename__ = "price_history" + + id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + product_code = Column(String(100), nullable=False) # 상품 식별(조회 키) + job_id = Column(UUID(as_uuid=True), nullable=True) # 검색 잡 연결(추적) + triggered_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # X축(검색 실행 시각) + outcome = Column(String(20), nullable=False) # found / not_found + matched_count = Column(Integer, nullable=True) # AI 매칭 건수 + + naver_lowest = Column(Integer, nullable=True) # 네이버 최저가(같은 상품) + naver_name = Column(String(300), nullable=True) + naver_url = Column(Text, nullable=True) + coupang_lowest = Column(Integer, nullable=True) # 쿠팡 최저가(같은 상품) + coupang_name = Column(String(300), nullable=True) + coupang_url = Column(Text, nullable=True) + final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심) + final_source = Column(String(20), nullable=True) # 최종 최저가 소스 + # 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...]. + # 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지. + by_mall = Column(JSONB, nullable=True) + + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) + + __table_args__ = ( + # 특정 상품 시계열 조회 최적화 + Index("ix_price_history_product", "product_code", "triggered_at"), + ) + + +class bot_detection(MAIN_BASE): + """봇 감지 이력 — '이 IP로 몇 번째 요청에서, 어떤 방식으로 차단됐나'를 축적해 패턴 분석. + (예: SELECT avg(ip_request_no) → IP당 평균 몇 요청 만에 감지되는지)""" + + @staticmethod + def DBType(): + return DBType.MAIN.value + + __tablename__ = "bot_detection" + + id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + source = Column(String(20), nullable=False) # coupang 등 + query = Column(String(300), nullable=True) # 감지 당시 검색어 + ip_request_no = Column(Integer, nullable=True) # 현재 IP(브라우저)로 몇 번째 요청이었나 + proxy_port = Column(Integer, nullable=True) # 사용 중이던 프록시 포트(=IP 세션) + elapsed_sec = Column(Integer, nullable=True) # 브라우저 실행 후 경과(초) + marker = Column(String(120), nullable=True) # 감지 근거(차단 페이지 마커) + headless = Column(Boolean, nullable=True) + html_len = Column(Integer, nullable=True) # 응답 길이(차단 페이지는 작음) + created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) diff --git a/lps/common/enums.py b/lps/common/enums.py new file mode 100644 index 0000000..b275871 --- /dev/null +++ b/lps/common/enums.py @@ -0,0 +1,71 @@ +from enum import Enum, auto + +from fastapi import HTTPException + + +class ErrorType(Enum): + """서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다. + HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다. + 도메인 로직이 생기면 각 구간(예: 1500~ LPS 전용)을 이어서 추가한다. + """ + + SUCCESS = 0 + FAIL = 1 + + # DB 에러 + DB_RUN_FAILED = 10 + DB_ALREADY_SAME_KEY = auto() + DB_INVALID_KEY = auto() + DB_EMPTY_DATA = auto() + DB_INVALID_TYPE = auto() + + # 요청/직렬화 에러 + JSON_PARSE_ERROR = 100 + INVALID_REQUEST_DATA = auto() + INTERNAL_EXCEPTION = auto() + + # http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션 + HTTP_INVALID_CLIENT_REQUEST = 419 + HTTP_TO_MANY_REQUEST = 429 + HTTP_INVALID_CLIENT_ACCESS = 433 + + # LPS 도메인 에러 (1500~) + LPS_JOB_NOT_FOUND = 1500 # 잡 없음/잘못된 job_id + + +# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. +EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) +EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name) +EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name) + + +class DBType(Enum): + """논리 DB 식별자. 물리적으로 같은 DB 라도 도메인별로 논리 구분한다. + DB 가 늘어나면 여기에 추가하고 db_session_manager 의 엔진 맵에도 등록한다. + """ + + MAIN = 1 # LPS 기본 DB + + +class DBWRType(Enum): + """Read/Write 분리. 조회는 READ(복제), 변경은 WRITE(주 DB).""" + + DB_READ = 1 + DB_WRITE = 2 + + +class JobStatus(Enum): + """작업 큐 상태. 전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. + 실패는 재시도 가능하면 PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter).""" + + PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상 + RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수 + DONE = 3 # 완료 + DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상) + + +class JobType(Enum): + """작업 종류. 무거운 잡(SEARCH=브라우저)과 가벼운 잡을 구분해 워커/동시성을 분리한다.""" + + SEARCH = 1 # 최저가 검색(쿠팡=브라우저) — 무거움 + OUTBOX = 2 # 외부 API 결과 전송(재시도 엔진 공유) — 가벼움 diff --git a/lps/common/logger.py b/lps/common/logger.py new file mode 100644 index 0000000..802f664 --- /dev/null +++ b/lps/common/logger.py @@ -0,0 +1,43 @@ +import sys +import traceback +from datetime import datetime, timezone + + +class _Logger: + """원본 DerbyServer LOG 인터페이스를 간소화한 버전. + LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다. + """ + + def __init__(self): + self._prefix = "" + + def SetPrefix(self, prefix: str): + self._prefix = prefix + + def _now(self) -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + def _write(self, level: str, msg): + head = f"{self._now()} [{level}]" + if self._prefix: + head += f"[{self._prefix}]" + print(f"{head} {msg}", file=sys.stderr if level in ("WARN", "ERROR") else sys.stdout) + + def d(self, msg): + self._write("DEBUG", msg) + + def i(self, msg): + self._write("INFO", msg) + + def w(self, msg): + self._write("WARN", msg) + + def e(self, msg): + self._write("ERROR", msg) + traceback.print_stack() + + def e_no_callstack(self, msg): + self._write("ERROR", msg) + + +LOG = _Logger() diff --git a/lps/common/models/gmodel.py b/lps/common/models/gmodel.py new file mode 100644 index 0000000..409d31e --- /dev/null +++ b/lps/common/models/gmodel.py @@ -0,0 +1,44 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from common.enums import ErrorType + + +class StructModel: + """프로토콜/구조체 식별용 마커 클래스.""" + + pass + + +class ErrorInfo(BaseModel, StructModel): + """모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다.""" + + success: Optional[bool] = Field(True, description="처리 성공 여부 (성공 시 true)") + code: Optional[int] = Field(ErrorType.SUCCESS.value, description="결과 코드 (ErrorType, 0=성공)") + desc: Optional[str] = Field(ErrorType.SUCCESS.name, description="결과 코드 이름 (ErrorType.name)") + + def SetResult(self, enum: ErrorType): + if enum is not None: + self.success = ErrorType.SUCCESS.value == enum.value + self.code = enum.value + self.desc = enum.name + + +# ---- Protocol 규약 ------------------------------------------------------- +# 모든 통신 패킷은 WebPacketProtocol 을 상속한다. +# 요청 : Req_xxx (Req_WebPacketProtocol) +# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다. +# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다. +class WebPacketProtocol(BaseModel, StructModel): + pass + + +class Req_WebPacketProtocol(WebPacketProtocol): + pass + + +class Res_WebPacketProtocol(WebPacketProtocol): + # default_factory 로 인스턴스마다 새 ErrorInfo 를 생성한다 (mutable default 공유 방지). + result: ErrorInfo = Field(default_factory=ErrorInfo, description="공통 처리 결과 (성공 여부/코드/설명)") + msg: Optional[str] = Field(None, description="부가 메시지 (선택)") diff --git a/lps/common/singleton.py b/lps/common/singleton.py new file mode 100644 index 0000000..47d87e6 --- /dev/null +++ b/lps/common/singleton.py @@ -0,0 +1,16 @@ +class Singleton: + _init = False + + def __new__(cls, *args, **kwargs): + if not hasattr(cls, "instance"): + cls.instance = super(Singleton, cls).__new__(cls) + + return cls.instance + + @classmethod + def is_init(cls): + return cls._init + + @classmethod + def set_init(cls): + cls._init = True diff --git a/lps/common/utils/gtime.py b/lps/common/utils/gtime.py new file mode 100644 index 0000000..937513b --- /dev/null +++ b/lps/common/utils/gtime.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone, timedelta + + +class GTime: + """서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)""" + + @staticmethod + def UTC() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + @staticmethod + def UTCStr(fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + return GTime.UTC().strftime(fmt) + + @staticmethod + def AddMinutes(minutes: int) -> datetime: + return GTime.UTC() + timedelta(minutes=minutes) + + @staticmethod + def AddDays(days: int) -> datetime: + return GTime.UTC() + timedelta(days=days) diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example new file mode 100644 index 0000000..469db4d --- /dev/null +++ b/lps/config/config.local.toml.example @@ -0,0 +1,74 @@ +# 복사해서 사용: cp config.local.toml.example config.local.toml +# 실제 config.local.toml 은 시크릿 포함이라 커밋하지 않는다(.gitignore: *.toml). +# 모든 서버는 APP_ENV=local 로 띄우며 이 파일을 읽는다. +# +# ── 프로덕션: 시크릿을 이미지에 굽지 말고 env 로 주입(server_configs 가 override) ── +# DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME +# OPENAI_API_KEY / OPENAI_MODEL +# DECODO_HOST / DECODO_USERNAME / DECODO_PASSWORD / DECODO_COST_PER_GB +# NAVER_KEYS="id1:secret1,id2:secret2" +# LPS_PROFILE_DIR=/profiles (Chrome 프로필 영속 볼륨), LPS_CHROME_EXECUTABLE=/usr/bin/chromium +# → 배포 시엔 아래 시크릿 값을 비워두고 위 env 로 채우면 이미지에 시크릿이 안 남는다. +[WebServerConfig] +server_name = "LpsServer" +port = 9600 +process_count = 1 +is_ssl = false +is_test = true +# CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 5173=vite dev. +cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] + +[LogConfig] +print_console = true +log_level = "debug" + +# DB Read/Write 분리. 도커 실행 시 host 는 docker-compose 의 DB_HOST 로 override. +# 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require". +# LPS 도메인 로직/테이블이 생기기 전까지는 접속하지 않으므로(엔진 lazy) placeholder 여도 부팅된다. +[MainDBConfig] +db_type = "postgresql" +name = "lps_db" +write_host = "127.0.0.1" +write_port = 5432 +write_id = "" +write_pw = "" +read_host = "127.0.0.1" +read_port = 5432 +read_id = "" +read_pw = "" +show_log = false +pool_size = 10 # connection_budget>0 이면 무시(자동 산정). budget=0 일 때만 이 값 사용. +max_overflow = 20 # 〃 +# 커넥션 예산(자동 산정). process_count 에 맞춰 pool_size/max_overflow 를 자동 계산: +# (pool+overflow) × 2엔진 × process_count ≤ connection_budget. +# 'lps API 가 쓸 총 커넥션 상한' — 공유 PG(max_connections)·동거 서비스(worker 등)를 고려한 값. +# 예) 전용 PG(max_connections=100)면 90 근처, 공유 PG면 40 권장. 0 이면 자동 끔(위 pool 값 사용). +connection_budget = 40 # env DB_CONNECTION_BUDGET 로 override +sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full" + +# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ── + +# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함. +[NaverConfig] +[[NaverConfig.keys]] +id = "" +secret = "" +# 추가 키는 아래처럼 블록을 더 넣으면 됨: +# [[NaverConfig.keys]] +# id = "..." +# secret = "..." + +# AI 유사도 판정/검색어 생성 (OpenAI) +[OpenAIConfig] +api_key = "" +model = "gpt-4o-mini" + +# DECODO residential 프록시 (쿠팡 전용, 포트기반 sticky). 값 다 채우면 활성(비면 프록시 미사용). +[DecodoConfig] +host = "" # 예: gate.decodo.com +username = "" # 대시보드 USERNAME (예: sppd6a3ze3) +password = "" # 대시보드 PASSWORD +port_start = 0 # 예: 10001 +port_end = 0 # 예: 10010 +session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치 +cost_per_gb = 0.0 # DECODO 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게, 예 3.0) diff --git a/lps/config/config_loader.py b/lps/config/config_loader.py new file mode 100644 index 0000000..8e1f7a0 --- /dev/null +++ b/lps/config/config_loader.py @@ -0,0 +1,37 @@ +import tomllib +from typing import Optional, Type, Dict, TypeVar +from pydantic import BaseModel + + +class ConfigModel(BaseModel): + pass + + +# APP_ENV +# local : 로컬 환경(개인 pc) +# dev : 개발환경 (사내 pc) +# prod : 서비스 환경 (클라우드 서버) +# +# 실행시 환경변수 설정 +# linux : export APP_ENV=dev +# window : set APP_ENV=dev +class Configs: + ConfigType = TypeVar("ConfigType", bound=ConfigModel) + + def __init__(self, file_path: str): + self._settings: Dict[Type["Configs.ConfigType"], "Configs.ConfigType"] = self._load_settings_from_toml(file_path) + + def _load_settings_from_toml(self, file_path: str) -> Dict[Type[ConfigType], ConfigType]: + with open(file_path, "rb") as f: + toml_content = tomllib.load(f) + + config_subclasses = ConfigModel.__subclasses__() + configs = { + config_class: config_class.model_validate(toml_content[config_class.__name__]) + for config_class in config_subclasses + if config_class.__name__ in toml_content + } + return configs + + def get(self, config_class: Type[ConfigType]) -> Optional[ConfigType]: + return self._settings.get(config_class) diff --git a/lps/config/config_models.py b/lps/config/config_models.py new file mode 100644 index 0000000..a8b913c --- /dev/null +++ b/lps/config/config_models.py @@ -0,0 +1,78 @@ +from pydantic import BaseModel + +from config.config_loader import ConfigModel + + +class WebServerConfig(ConfigModel): + server_name: str = "" + port: int = 0 + process_count: int = 1 + is_ssl: bool = False + is_test: bool = False + # CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"] + cors_origins: list[str] = [] + + +class LogConfig(ConfigModel): + print_console: bool = True + log_level: str = "debug" + + +# DB Read/Write 분리 설정. +# 하나의 논리 DB 에 대해 write(주) / read(복제) 접속 정보를 각각 가진다. +class MainDBConfig(ConfigModel): + db_type: str = "postgresql" + name: str = "" + write_host: str = "" + write_port: int = 5432 + write_id: str = "" + write_pw: str = "" + read_host: str = "" + read_port: int = 5432 + read_id: str = "" + read_pw: str = "" + show_log: bool = False + # 커넥션 풀 사이징. 실제 동시 커넥션 = (pool_size + max_overflow) x 엔진수(R/W=2) x process_count. + pool_size: int = 10 + max_overflow: int = 20 + # 커넥션 예산(자동 산정). >0 이면 process_count 에 맞춰 pool_size/max_overflow 를 자동 계산한다: + # (pool+overflow)x2xprocess_count ≤ connection_budget 가 되도록. (이때 위 pool_size/max_overflow 는 무시) + # PG max_connections·공유 DB 동거 서비스(worker·negosium 등)를 고려한 'lps API 가 쓸 총 커넥션 상한'. + # 0 이면 자동 산정 끔(위 pool_size/max_overflow 그대로 사용). env DB_CONNECTION_BUDGET 로 override. + connection_budget: int = 40 + # SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure). + sslmode: str = "" + + +# ── 시크릿(API 키 등)도 TOML 로 통합 관리. config.local.toml 은 미커밋(*.toml). ── +# 배포는 이 파일을 마운트하거나(권장), 환경별로 바뀌는 값만 env override 한다(DB_HOST 등). + + +class NaverKey(BaseModel): + id: str = "" + secret: str = "" + + +class NaverConfig(ConfigModel): + """네이버 쇼핑 오픈API 키. 여러 개면 429/403 로테이션에 자동 포함.""" + + keys: list[NaverKey] = [] + + +class OpenAIConfig(ConfigModel): + """AI 유사도 판정/검색어 생성용 OpenAI.""" + + api_key: str = "" + model: str = "gpt-4o-mini" + + +class DecodoConfig(ConfigModel): + """DECODO residential 프록시(쿠팡 전용, 포트기반 sticky). 값이 다 차야 활성.""" + + host: str = "" + username: str = "" + password: str = "" + port_start: int = 0 + port_end: int = 0 + session_minutes: int = 10 + cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정) diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py new file mode 100644 index 0000000..2e31bb7 --- /dev/null +++ b/lps/config/server_configs.py @@ -0,0 +1,114 @@ +import os + +from config.config_loader import Configs +from config.config_models import ( + WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverKey, OpenAIConfig, DecodoConfig, +) + +# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경. +APP_ENV = os.environ.get("APP_ENV", "local") + +_config_dir = os.path.dirname(__file__) +_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml") + +# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml 사용 (test/docker 도 local 로 실행). +if not os.path.exists(_config_file): + raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.") + +configs = Configs(_config_file) + +web_server_config: WebServerConfig = configs.get(WebServerConfig) +log_config: LogConfig = configs.get(LogConfig) +main_db_config: MainDBConfig = configs.get(MainDBConfig) +# 시크릿 포함 설정도 TOML 로 통합. 섹션이 없으면 기본값(빈/비활성). +naver_config: NaverConfig = configs.get(NaverConfig) or NaverConfig() +openai_config: OpenAIConfig = configs.get(OpenAIConfig) or OpenAIConfig() +decodo_config: DecodoConfig = configs.get(DecodoConfig) or DecodoConfig() + + +# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로. +def _apply_db_env_override(cfg: MainDBConfig): + h = os.environ.get("DB_HOST") + if h: + cfg.write_host = cfg.read_host = h + if os.environ.get("DB_PORT"): + cfg.write_port = cfg.read_port = int(os.environ["DB_PORT"]) + if os.environ.get("DB_USER"): + cfg.write_id = cfg.read_id = os.environ["DB_USER"] + if os.environ.get("DB_PASSWORD"): + cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"] + if os.environ.get("DB_NAME"): + cfg.name = os.environ["DB_NAME"] + # 커넥션 예산 override (자동 산정용). env DB_CONNECTION_BUDGET. + if os.environ.get("DB_CONNECTION_BUDGET"): + cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"]) + + +def _autosize_pool(cfg: MainDBConfig, process_count: int): + """process_count 기반 커넥션 풀 자동 산정. + + 실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count. + 이 값이 connection_budget 를 넘지 않도록 pool_size/max_overflow 를 역산한다. + budget<=0 이면 비활성(toml 의 pool_size/max_overflow 그대로 사용). + + 반환: 자동 산정을 수행했으면 (pool_size, max_overflow), 아니면 None. + """ + budget = cfg.connection_budget + if budget <= 0: + return None + pc = max(1, process_count) + # 워커·엔진당 커넥션 = 예산 / (2엔진 × process_count). 최소 1(pool_size>=1) 보장. + # (예산 준수가 최우선 — process_count 가 너무 크면 엔진당 1커넥션까지 줄여서라도 예산을 넘지 않는다) + per_engine = max(1, budget // (2 * pc)) + # 정상(pool) 60% + 버스트(overflow) 40% 로 분할. + cfg.pool_size = max(1, round(per_engine * 0.6)) + cfg.max_overflow = max(0, per_engine - cfg.pool_size) + return cfg.pool_size, cfg.max_overflow + + +def _apply_pool_env_override(cfg: MainDBConfig): + """명시적 풀 override — 자동 산정보다 우선(테스트·특수 배포용).""" + if os.environ.get("DB_POOL_SIZE"): + cfg.pool_size = int(os.environ["DB_POOL_SIZE"]) + if os.environ.get("DB_MAX_OVERFLOW"): + cfg.max_overflow = int(os.environ["DB_MAX_OVERFLOW"]) + + +# 시크릿 env override — 프로덕션에선 API 키를 이미지에 굽지 않고 env(또는 시크릿매니저)로 주입한다. +# 로컬은 env 미설정 → config.local.toml 값 그대로. (배포 시 toml 의 시크릿은 비워두고 아래 env 로 주입 권장) +def _apply_secret_env_override(): + if os.environ.get("OPENAI_API_KEY"): + openai_config.api_key = os.environ["OPENAI_API_KEY"] + if os.environ.get("OPENAI_MODEL"): + openai_config.model = os.environ["OPENAI_MODEL"] + for k in ("host", "username", "password"): + v = os.environ.get(f"DECODO_{k.upper()}") + if v: + setattr(decodo_config, k, v) + # 포트 범위도 시크릿과 함께 env 주입 — example(플레이스홀더 0) 기반 이미지에서 이게 없으면 + # 자격증명을 넣어도 enabled=False(포트 0)로 프록시가 조용히 꺼진다. + for k in ("port_start", "port_end", "session_minutes"): + v = os.environ.get(f"DECODO_{k.upper()}") + if v: + setattr(decodo_config, k, int(v)) + if os.environ.get("DECODO_COST_PER_GB"): + decodo_config.cost_per_gb = float(os.environ["DECODO_COST_PER_GB"]) + # NAVER_KEYS="id1:secret1,id2:secret2" 형식으로 키 로테이션 주입 + nk = os.environ.get("NAVER_KEYS") + if nk: + naver_config.keys = [NaverKey(id=i, secret=s) + for i, s in (p.split(":", 1) for p in nk.split(",") if ":" in p)] + + +_apply_db_env_override(main_db_config) +_apply_secret_env_override() + +# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이). +if os.environ.get("PROCESS_COUNT"): + web_server_config.process_count = int(os.environ["PROCESS_COUNT"]) + +# 커넥션 풀 자동 산정: process_count(위에서 확정) 기준으로 예산 안에 맞춘다. +# → 멀티워커 배포 시 풀 오버서브스크립션(→커넥션 고갈)을 config 가 스스로 방지. +_pool_autosized = _autosize_pool(main_db_config, web_server_config.process_count) +# 명시적 DB_POOL_SIZE/DB_MAX_OVERFLOW 는 자동 산정보다 우선(최종 override). +_apply_pool_env_override(main_db_config) diff --git a/lps/conftest.py b/lps/conftest.py new file mode 100644 index 0000000..6c8468f --- /dev/null +++ b/lps/conftest.py @@ -0,0 +1,55 @@ +# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용). +# config.server_configs 가 import 되는 순간 config..toml 을 읽으므로 가장 먼저 설정. +import os + +os.environ.setdefault("APP_ENV", "local") + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine + +from common.database.model.models import MAIN_BASE +from config.server_configs import main_db_config + + +def _write_url(cfg) -> str: + pw = f":{cfg.write_pw}" if cfg.write_pw else "" + return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}" + + +@pytest_asyncio.fixture +async def db_engine(): + """테스트용 스키마를 보장한다(실제 DB 필요). 도메인 테이블이 생기면 이 fixture 를 쓰는 테스트를 추가한다. + + 앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB 에 접속하므로 여기서 만든 스키마를 그대로 공유한다. + """ + engine = create_async_engine(_write_url(main_db_config)) + async with engine.begin() as conn: + await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def _dispose_app_engines(): + """테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다. + (이벤트 루프 종료 후 커넥션이 GC 되며 나오는 'Event loop is closed' 경고 제거) + """ + yield + from common.database.db_session_manager import DB_SESSION_MNG + + await DB_SESSION_MNG.dispose_all() + + +@pytest_asyncio.fixture +async def client(): + """앱을 실제 네트워크 없이 호출하는 httpx 클라이언트 (ASGITransport). + + 아직 도메인 테이블이 없어 DB 없이도 부팅되므로 db_engine 에 의존하지 않는다. + DB 를 쓰는 도메인 테스트를 추가할 땐 인자에 db_engine 을 받아 스키마를 보장한다. + """ + from router.router import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/lps/crud/.gitkeep b/lps/crud/.gitkeep new file mode 100644 index 0000000..c8f6d1c --- /dev/null +++ b/lps/crud/.gitkeep @@ -0,0 +1,2 @@ +# crud 폴더 placeholder — DB 접근 계층(crud)을 여기에 추가한다. +# backend/crud/*.py 컨벤션: ISomethingCRUD(ABC) + SomethingCRUD 구현, DB_SESSION_MNG.execute 등 저수준 연산 호출. diff --git a/lps/crud/bot_detection.py b/lps/crud/bot_detection.py new file mode 100644 index 0000000..e1692f1 --- /dev/null +++ b/lps/crud/bot_detection.py @@ -0,0 +1,37 @@ +"""봇 감지 이력 기록 CRUD — '몇 번째 요청/어떤 포트에서 감지됐나'를 축적(패턴 분석용).""" + +from sqlalchemy import text + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType + + +class BotDetectionLog: + DB = DBType.MAIN.value + + async def record(self, event: dict): + """감지 이벤트 1건 저장. 로깅 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다.""" + sql = text(""" + INSERT INTO bot_detection (source, query, ip_request_no, proxy_port, elapsed_sec, marker, headless, html_len) + VALUES (:source, :query, :ip_request_no, :proxy_port, :elapsed_sec, :marker, :headless, :html_len) + """) + params = {k: event.get(k) for k in + ("source", "query", "ip_request_no", "proxy_port", "elapsed_sec", "marker", "headless", "html_len")} + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(sql, params) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + + async def recent_count(self, minutes: int = 60) -> int: + """최근 N분간 봇 감지(차단) 건수 — 차단율 급증 알림·모니터링용.""" + sql = text("SELECT count(*) FROM bot_detection WHERE created_at > now() - make_interval(mins => :m)") + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + return int((await s.execute(sql, {"m": minutes})).scalar() or 0) + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) diff --git a/lps/crud/job_crud.py b/lps/crud/job_crud.py new file mode 100644 index 0000000..c221d24 --- /dev/null +++ b/lps/crud/job_crud.py @@ -0,0 +1,237 @@ +"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다. + +레퍼런스의 반면교사를 전부 뒤집는다: + - 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING. + → 워커/디스패처가 몇이든 같은 잡 이중 할당이 원천 불가. fetch 와 claim 을 분리하지 않는다. + - 모든 전이는 **조건부 CAS**(WHERE 에 status/worker_id 가드) + RETURNING. + - 복구는 timeout 추측이 아니라 **lease 만료 소유권**(reaper 가 회수). + - 재시도/백오프/dead-letter 를 큐에 내장(스크립트 난립 제거). 외부전송도 OUTBOX 잡으로. +""" + +import json + +from sqlalchemy import text + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType, JobStatus + + +# 잡 적재 시 워커를 즉시 깨우는 LISTEN/NOTIFY 채널(폴링 제거). +JOB_NOTIFY_CHANNEL = "lps_job" + + +def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float: + """지수 백오프(초). attempts 회 시도 후 다음 재시도까지 대기 = base * 2^(attempts-1), cap 상한.""" + return min(cap, base * (2 ** max(0, attempts - 1))) + + +class JobQueue: + DB = DBType.MAIN.value + + async def _tx(self, fn): + """쓰기 트랜잭션(commit/rollback 은 여기서 책임).""" + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + res = await fn(s) + await s.commit() + return res + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + + # ---- 적재 ----------------------------------------------------------- + async def enqueue(self, job_type: int, payload: dict, priority: int = 100, dedupe_key: str | None = None, max_attempts: int = 3): + """잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환.""" + sql = text(""" + INSERT INTO job (job_type, priority, payload, dedupe_key, max_attempts) + VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma) + ON CONFLICT (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL + DO NOTHING + RETURNING job_id + """) + + async def run(s): + row = (await s.execute(sql, { + "t": job_type, "p": priority, "payload": json.dumps(payload), + "dk": dedupe_key, "ma": max_attempts, + })).first() + if row: + # 커밋 시 전달됨 → LISTEN 중인 유휴 워커를 즉시 깨운다(중복 스킵 시엔 알림 안 함). + await s.execute(text("SELECT pg_notify(:ch, '')"), {"ch": JOB_NOTIFY_CHANNEL}) + return str(row[0]) if row else None + + return await self._tx(run) + + # ---- 원자적 claim --------------------------------------------------- + async def claim(self, worker_id: str, lease_sec: int = 120): + """대기 잡 1건을 원자적으로 점유. 없으면 None. + FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가.""" + sql = text(""" + UPDATE job SET + status = 2, + worker_id = :wid, + lease_until = now() + make_interval(secs => :lease), + run_started_at = now(), + attempts = attempts + 1, + updated_at = now() + WHERE job_id = ( + SELECT job_id FROM job + WHERE status = 1 AND run_after <= now() + ORDER BY priority ASC, created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING job_id, job_type, payload, attempts, max_attempts + """) + + async def run(s): + row = (await s.execute(sql, {"wid": worker_id, "lease": lease_sec})).mappings().first() + if not row: + return None + d = dict(row) + d["job_id"] = str(d["job_id"]) + if isinstance(d.get("payload"), str): + d["payload"] = json.loads(d["payload"]) + return d + + return await self._tx(run) + + # ---- 완료/실패 (소유권 가드) --------------------------------------- + async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool: + sql = text(""" + UPDATE job SET status = 3, result = CAST(:result AS jsonb), + lease_until = NULL, worker_id = NULL, updated_at = now() + WHERE job_id = :id AND status = 2 AND worker_id = :wid + RETURNING job_id + """) + + async def run(s): + row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "result": json.dumps(result) if result is not None else None})).first() + return row is not None + + return await self._tx(run) + + async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None: + """실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter). + 전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None.""" + sql = text(""" + UPDATE job SET + status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, + run_after = CASE WHEN attempts >= max_attempts THEN run_after + ELSE now() + make_interval(secs => :backoff) END, + last_error = :err, + lease_until = NULL, + worker_id = NULL, + updated_at = now() + WHERE job_id = :id AND status = 2 AND worker_id = :wid + RETURNING status + """) + + async def run(s): + row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "err": error[:2000], "backoff": backoff_sec})).first() + return int(row[0]) if row else None + + return await self._tx(run) + + # ---- lease 갱신(heartbeat) / 회수(reaper) --------------------------- + async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool: + sql = text(""" + UPDATE job SET lease_until = now() + make_interval(secs => :lease), updated_at = now() + WHERE job_id = :id AND worker_id = :wid AND status = 2 + RETURNING job_id + """) + + async def run(s): + row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "lease": lease_sec})).first() + return row is not None + + return await self._tx(run) + + async def reap(self) -> list[str]: + """만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD. + 회수된 job_id 목록 반환.""" + sql = text(""" + UPDATE job SET + status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, + run_after = now(), + last_error = COALESCE(last_error, '') || ' [lease-expired reclaim]', + lease_until = NULL, + worker_id = NULL, + updated_at = now() + WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now() + RETURNING job_id + """) + + async def run(s): + rows = (await s.execute(sql)).all() + return [str(r[0]) for r in rows] + + return await self._tx(run) + + # ---- 단건 조회 ------------------------------------------------------ + async def get(self, job_id: str) -> dict | None: + """잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값).""" + sql = text(""" + SELECT job_id, job_type, status, priority, attempts, max_attempts, + result, last_error, run_after, created_at, updated_at + FROM job WHERE job_id = :id + """) + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + row = (await s.execute(sql, {"id": job_id})).mappings().first() + if not row: + return None + d = dict(row) + d["job_id"] = str(d["job_id"]) + if isinstance(d.get("result"), str): + d["result"] = json.loads(d["result"]) + return d + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + + # ---- 관측(관리 API/메트릭용) --------------------------------------- + async def counts(self) -> dict[str, int]: + """상태별 잡 개수(관리 API·알림용). 수동 psql 스크립트를 대체한다.""" + async def run(s): + rows = (await s.execute(text("SELECT status, count(*) FROM job GROUP BY status"))).all() + by_val = {int(st): int(c) for st, c in rows} + return {js.name: by_val.get(js.value, 0) for js in JobStatus} + + return await self._tx(run) + + async def ops(self) -> dict: + """운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + + 최근 1시간 DEAD + stuck(좀비 신호). stuck 은 두 축 — lease 만료(워커 사망인데 reaper + 미회수) OR 실행 10분 초과(핸들러 행 — heartbeat 가 lease 를 계속 갱신해 lease 축엔 안 + 잡히므로 run_started_at 로 따로 본다. 잡 데드라인 300s 가 정상 작동하면 여기 안 온다).""" + sql = text(""" + SELECT + count(*) FILTER (WHERE status = 1) AS pending, + count(*) FILTER (WHERE status = 2) AS running, + count(*) FILTER (WHERE status = 3) AS done, + count(*) FILTER (WHERE status = 4) AS dead, + count(*) FILTER (WHERE status = 4 AND updated_at > now() - interval '1 hour') AS dead_1h, + count(*) FILTER (WHERE status = 2 AND ( + (lease_until IS NOT NULL AND lease_until < now()) + OR run_started_at < now() - interval '10 minutes' + )) AS stuck_running, + COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec + FROM job + """) + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + row = (await s.execute(sql)).mappings().first() + return {k: int(v) for k, v in dict(row).items()} + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + + async def ping(self) -> bool: + """DB 도달성 확인(readiness). 실패 시 예외.""" + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + await s.execute(text("SELECT 1")) + return True + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) diff --git a/lps/crud/negative_cache.py b/lps/crud/negative_cache.py new file mode 100644 index 0000000..f657045 --- /dev/null +++ b/lps/crud/negative_cache.py @@ -0,0 +1,47 @@ +"""네거티브 캐시 CRUD — not_found 결론을 TTL 동안 기억. + +같은 상품 재요청이 즉시 재검색(브라우저+API+LLM 비용)하는 것을 막는다. +TTL 만료 후에는 다시 검색 허용(입고 가능성). key 는 보통 product_code.""" + +from sqlalchemy import text + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType + + +class NegativeCache: + DB = DBType.MAIN.value + + async def is_negative(self, key: str) -> bool: + """key 가 아직 유효한 not_found 로 캐시돼 있으면 True.""" + if not key: + return False + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + row = (await s.execute( + text("SELECT 1 FROM search_negative WHERE key = :k AND until > now()"), + {"k": key}, + )).first() + return row is not None + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + + async def put(self, key: str, ttl_sec: int = 86400, reason: str = "not_found"): + """key 를 ttl_sec 동안 not_found 로 기록(upsert).""" + if not key: + return + sql = text(""" + INSERT INTO search_negative (key, until, reason) + VALUES (:k, now() + make_interval(secs => :ttl), :r) + ON CONFLICT (key) DO UPDATE + SET until = EXCLUDED.until, reason = EXCLUDED.reason, created_at = now() + """) + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(sql, {"k": key, "ttl": ttl_sec, "r": reason[:200]}) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py new file mode 100644 index 0000000..9a40011 --- /dev/null +++ b/lps/crud/price_history.py @@ -0,0 +1,67 @@ +"""최저가 스냅샷 CRUD — 트리거 시점마다 기록하고, 상품별 시계열로 조회(그래프).""" + +import json + +from sqlalchemy import text + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType + +_FIELDS = ( + "product_code", "job_id", "outcome", "matched_count", + "naver_lowest", "naver_name", "naver_url", + "coupang_lowest", "coupang_name", "coupang_url", + "final_lowest", "final_source", +) + + +class PriceHistory: + DB = DBType.MAIN.value + + async def record(self, event: dict): + """스냅샷 1건 저장. triggered_at 은 now()(관측 시각). 로깅 실패가 검색을 막지 않도록 호출부에서 예외 처리.""" + sql = text(""" + INSERT INTO price_history + (product_code, job_id, outcome, matched_count, + naver_lowest, naver_name, naver_url, + coupang_lowest, coupang_name, coupang_url, + final_lowest, final_source, by_mall) + VALUES + (:product_code, :job_id, :outcome, :matched_count, + :naver_lowest, :naver_name, :naver_url, + :coupang_lowest, :coupang_name, :coupang_url, + :final_lowest, :final_source, CAST(:by_mall AS jsonb)) + """) + params = {k: event.get(k) for k in _FIELDS} + by_mall = event.get("by_mall") + params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(sql, params) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + + async def list_by_product(self, product_code: str, limit: int = 100) -> list[dict]: + """상품의 최근 스냅샷을 시각 오름차순(그래프 플롯용)으로 반환. 최근 limit 건.""" + sql = text(""" + SELECT * FROM ( + SELECT triggered_at, outcome, matched_count, + naver_lowest, naver_name, naver_url, + coupang_lowest, coupang_name, coupang_url, + final_lowest, final_source, by_mall + FROM price_history + WHERE product_code = :pc + ORDER BY triggered_at DESC + LIMIT :lim + ) t ORDER BY triggered_at ASC + """) + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value) + try: + rows = (await s.execute(sql, {"pc": product_code, "lim": limit})).mappings().all() + return [dict(r) for r in rows] + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) diff --git a/lps/docs/api.md b/lps/docs/api.md new file mode 100644 index 0000000..028ab6e --- /dev/null +++ b/lps/docs/api.md @@ -0,0 +1,158 @@ +# 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": "manual" // (선택) 요청 유형 → 우선순위 + } + ] +} +``` + +> **specification은 나눌 필요 없이** "1박스, 160개입"처럼 통째로 넣으면 AI가 해석합니다. + +**`job_type` → 우선순위** (낮을수록 먼저): +| 값 | 우선순위 | 의미 | +|----|:---:|------| +| `new_product` | 1 (최우선) | 신규 상품 등록 시 | +| `manual` | 2 (기본) | 담당자 수동 요청 | +| `partner` | 3 | 외부 시스템/파트너 연동 | +| `batch` | 4 (최하위) | 정기 배치 | + +(알 수 없는 값은 batch와 동급으로 처리) + +**응답** +```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": "...", + "shipping_fee": 0, "shipping_type": "rocket" }, + "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} ], + "metrics": { // 이 검색 1건이 쓴 리소스/비용/시간 + "duration_ms": 21500, + "ai": { "calls": 2, "prompt_tokens": 5200, "completion_tokens": 180, "est_cost_usd": 0.000888 }, + "crawl": { "fetches": 3, "html_bytes": 1560000, "malls_crawled": ["gmarket"] }, + "source_ms": { "naver": 480, "coupang": 12300, "gmarket": 8700 } + } + } +} +``` +- `output.stages` = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용). +- `output.metrics` = 검색 1건의 원가. `ai`(호출·토큰·추정 비용$), `crawl`(fetch 수·**실제 전송 바이트**·크롤한 몰), `source_ms`(소스별 소요), `duration_ms`(전체). 바이트는 CDP `Network.loadingFinished`의 encodedDataLength(실제 프록시 전송량)로 측정. `proxy_bytes`(네이버 직접 제외)로 DECODO 비용 산정. +- 없는 job_id/잘못된 형식 → `result.desc = "LPS_JOB_NOT_FOUND"`. + +> **⚠️ 가격의 의미 (배송비)** +> - `price` 는 **상품가**입니다. 배송비 포함 여부는 `shipping_fee`/`shipping_type` 으로 판단합니다. +> - **쿠팡**: 검색 화면의 배송 신호를 파싱해 채웁니다 — +> `shipping_type`: `rocket`(로켓배송, 와우 무료/일반 19,800원↑ 무료) · `rocket_merchant`(판매자로켓) · `free`(명시 무료) · `paid`(유료, `shipping_fee`에 금액) · `null`(미확인) +> - **네이버**: 오픈API `lprice` 는 **배송비 제외** 상품가라 둘 다 항상 `null` 입니다. +> 가격비교(카탈로그) 화면의 기본 표시는 "**배송비포함** 최저가"라서 **API 값과 다르게 보이는 것이 정상**입니다 +> (예: API 5,880원 vs 화면 7,520원). 카탈로그 페이지 자동 수집은 네이버 캡차로 차단되어 미지원. + +```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..534ff1e --- /dev/null +++ b/lps/docs/architecture.md @@ -0,0 +1,174 @@ +# 아키텍처 — 어떻게 동작하는가 + +[← README로](../README.md) + +## 1. 구성요소 (한눈에) + +| 구성요소 | 파일 | 역할 | +|---------|------|------| +| **API 서버** | `web_main.py` | 검색 요청을 받아 **큐에 적재**만 함(빠르게 응답). 상태·이력 조회 제공 | +| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) | +| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 | +| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) | +| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`LPS_FALLBACKS`) | +| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 | +| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) | + +> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다. + +## 2. 처리 파이프라인 (워커가 하는 일) + +한 건의 검색 작업은 아래 단계를 거칩니다. 각 단계의 통과 건수는 `stages`로 기록됩니다(관측). + +``` +① 네거티브 캐시 확인 + 최근 "없음"으로 확인된 상품이면 → 재검색 생략(비용 절약) + +② 소스 검색 (재정제 루프, 최대 3라운드) + 라운드1: 원본 검색어 → 라운드2: AI 정밀 검색어 → 라운드3: 광역 검색어 + 각 라운드에서 네이버 ∥ 쿠팡 동시 검색 후 병합 + +③ 필터 + - mall 필터 (필요 시 특정 쇼핑몰만/제외) + - 가격 밴드 (요청에 현재가가 있으면 ±범위 밖 제거) + +④ 이상치 제거 (IQR) + 비정상적으로 싸거나 비싼 항목 제거 (오매칭·묶음 등) + +⑤ AI 같은 상품 판정 + 후보 중 "찾는 상품과 동일한 것"만 선별 (액세서리·다른 규격 제외) + → 매칭 있으면: ⑤-1 오픈마켓 폴백 → 최저가순 정렬 → 상위 N개 반환 (found) + → 매칭 0건 + 소스 정상: 다음 라운드로 + → 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도) + +⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_FALLBACKS 로 켬**) + 네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합 + ("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리) + ※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지). + 배경·재가동 절차는 decision-openmarket-crawler.md + +⑥ 결과 저장 + 최저가 확정 + 최저가 이력 스냅샷 기록(몰별 by_mall 포함) +``` + +모든 라운드에서 못 찾으면 → **not_found(정상 종료)** + 네거티브 캐시에 기록. + +## 3. 재시도 로직 — "왜 실패했나"에 따라 다르게 + +실패는 성격이 다르므로 **두 종류로 분리**해서 처리합니다. (섞으면 무한 재시도·오작동) + +| 실패 종류 | 예시 | 대응 | +|----------|------|------| +| **기술적 실패** | 네트워크·쿠팡 차단·API 한도·AI 오류 | 잠시 후 재시도(지수 백오프), 여러 번 실패하면 **DEAD**(사람이 확인) | +| **검색어 문제** | "맥심 커피"가 너무 광범위 → 0건 | **검색어를 바꿔** 재시도(정밀→광역), 다 실패하면 **not_found** | +| **진짜 없는 상품** | 실제로 안 파는 상품 | 재시도 무의미 → **not_found로 정상 종료** (에러 아님) | + +**무한 재시도 방지**: 기술적 재시도(횟수 상한)·검색어 재시도(라운드 상한) 둘 다 유한합니다. "못 찾음"은 **실패가 아니라 정상적인 답**으로 처리해 쌓이지 않습니다. + +## 4. 안티봇 대응 (소스별로 다름) + +브라우저 소스는 `BrowserSearchAdapter`(services/search/browser_base.py) 위에서 patchright(스텔스 Chrome)로 뚫고, 사이트별 차이는 훅으로 분리합니다. + +| 소스 | 안티봇 | 대응 | +|------|--------|------| +| **쿠팡** | Akamai(JS 챌린지, 여러 flavor: 챌린지·Edge Access Denied·권한제한) | 실제 Chrome 통과 + 다종 마커 감지→IP 회전. 리소스 차단 OK(대역폭↓) | +| **G마켓·옥션**(ESM) | **Cloudflare Turnstile**('사람인지 확인' 체크박스) | patchright가 콜드 ~12초에 **자동 통과**, `cf_clearance` 쿠키로 이후 요청은 웜(~5초). 인터랙티브 체크박스는 best-effort 클릭 | +| **11번가** | 경량(모바일은 robot 차단→PC 사용) | PC 크롤. 지연 로딩 → 스크롤 트리거 | +| **네이버** | 없음(공식 오픈API) | httpx 직접 호출 + 키 로테이션 | + +**핵심 메커니즘** +- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전. +- **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름). +- **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다. +- **폴백 데드라인**: 오픈마켓 크롤은 '보강'이라 각 크롤에 시간 상한(기본 15초)을 둬, 한 몰이 안 풀려도 전체 지연이 늘지 않게 합니다. +- **유휴 브라우저 정리**: 일정 시간(기본 120초) 검색이 없는 소스의 Chrome을 닫아 메모리를 회수(쿠키는 프로필에 남아 재기동해도 웜 유지). + +## 5. 검색 원가 계측 (리소스·비용·시간) + +검색 1건이 쓰는 것을 잡 단위로 집계해 `result.metrics`에 남깁니다(API/FE 노출). +- **AI**: 호출 수 + 토큰(prompt/completion) + 추정 비용($, 모델 단가) +- **크롤 대역폭**: CDP `Network.loadingFinished`의 **실제 전송 바이트**(DOM 크기가 아님). 프록시 경유분(네이버 직접 제외)으로 **DECODO 비용**($/GB) 산정 +- **컴포넌트별 비용**: `cost = { ai_usd, proxy_usd, total_usd }` +- **시간**: 소스별 소요 + 전체 + +> 실측(2026-07): 상품당 ~$0.013 (AI ~$0.002 + **DECODO ~$0.011 = 87%**). 대역폭이 원가의 대부분 — 오픈마켓 크롤(브라우저 필수)이 주범. + +## 6. 다중 상품 병렬 처리 + +- `POST /search`에 상품 리스트를 주면 상품마다 잡을 큐에 적재. +- 워커 동시성(`WORKER_CONCURRENCY=N`)만큼 상품을 **진짜 병렬** 처리 — 워커마다 **자기 브라우저 세트**(프로필 분리 + 다른 프록시 IP)를 가져 공유 lock 병목을 없앰. 권장 N=2~3(로컬, Chrome 최대 4×N개). +- 부하 측정: `loadtest.py`(e2e 처리량·p50/p95 지연·AI/DECODO/총비용) · `loadtest/`(Locust API 부하, 멀티코어 벤치). + +### 6-1. API 멀티코어 스케일 & 커넥션 풀 자동산정 + +- **API 서버는 asyncio(스레드 1개) = 1 프로세스 1 코어**. 처리량을 코어만큼 올리려면 `process_count`(uvicorn 워커 수)를 늘린다. +- **함정**: 프로세스마다 독립 커넥션 풀을 열어 `(pool_size + max_overflow) × 2엔진(R/W) × process_count` 만큼 커넥션을 요구 → PG `max_connections`(기본 100)를 넘으면 **커넥션 고갈로 요청 실패 폭증**(부하테스트로 실증: 풀 10/20 · 4프로세스 = 240 요구 → 실패 1만+). +- **해결(자동)**: `MainDBConfig.connection_budget`(기본 40)를 두면 기동 시 `process_count`에 맞춰 `pool_size/max_overflow`를 **역산**해 `(pool+overflow)×2×process_count ≤ budget`을 스스로 보장(`server_configs._autosize_pool`). 워커를 늘려도 예산을 넘지 않는다. 기동 로그 `DB Pool : … = N conns (budget=…)`로 실효값 확인. +- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). env `DB_CONNECTION_BUDGET`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer. +- 상세·벤치 결과: [`../loadtest/README.md`](../loadtest/README.md). + +### 6-2. 왜 브라우저는 워커당 1세트인가 (더 띄우면 안 되나?) + +Chrome 프로세스 자체는 얼마든지 더 띄울 수 있다. 그런데도 워커:브라우저를 1:1로 두는 이유 — +정확히는 **"더 띄워봐야 이득이 0이고, 덜 띄우면(공유하면) 손해"**라서 1:1이 낭비도 병목도 없는 균형점이다. + +- **희소 자원은 브라우저가 아니라 "신원(identity)"**. 쿠팡용 브라우저 1개 = Chrome 프로필(Akamai 쿠키, + cf_clearance) + DECODO sticky IP(포트) 묶음이다. Akamai 는 쿠키와 IP 를 묶어 보므로(불일치 시 재챌린지) + 브라우저 추가 = 웜업 챌린지 1회 추가 + 프록시 포트 1개 소모 + 봇 감지 노출면 확대다. Chrome 은 공짜여도 + **쓸 수 있는 신원은 공짜가 아니다.** +- **검색 1건은 브라우저 여러 개로 못 쪼갠다.** 검색은 "페이지 열고 → 렌더 대기 → 파싱"의 순차 작업. + 병렬화 단위는 검색이 아니라 **잡(=상품)**이고, 잡의 동시 처리 주체가 워커다. 워커는 소스당 검색을 + 한 번에 하나만 하므로 소스당 브라우저 1개면 항상 꽉 채워 쓴다. +- **비율이 어긋나면**: 브라우저 > 워커 → 남는 브라우저는 놀면서 메모리·포트·웜업 비용만 차지(처리량 +0). + 브라우저 < 워커(공유) → 어댑터가 브라우저 상태(페이지·봇감지 회전 상태머신) 때문에 검색을 락으로 + 직렬화하므로 사실상 순차가 된다(2026-07-10 스톨 사건이 정확히 이 모습 — 웜업이 락을 쥐자 그 워커의 + 검색 전체가 정지). +- **처리량 상한도 브라우저 수가 아니다.** 신원 하나당 요청 간격을 일부러 2~8s 랜덤으로 벌린다(등간격 + 기계 요청 = 탐지 신호). 즉 신원당 처리량은 설계상 캡 — 처리량을 올리는 유일한 방법은 신원(=워커)을 + 늘리는 것이다. + +### 6-3. 워커 수 상한을 서버에서 파악하는 법 + +`워커 상한 = min( RAM캡, 포트캡, 실측 정체점 )` — 계산 가능한 하드캡 2개로 범위를 좁히고, 그 안에서 계단식 실측. + +**하드캡(계산)** + +- **RAM캡** = `(전체 RAM − OS/PG/API 여유분) × 0.7 ÷ 세트당 RSS`. + 세트당 RSS 는 워커 N개로 부하를 건 상태에서 chrome 프로세스 그룹 RSS 합 ÷ N (모니터 :9700 의 + 프로세스 그룹, 정밀하게는 `ps` RSS 합산). headful Chrome 세트는 리소스 차단을 해도 세트당 + 수백 MB~1GB — 16GB 서버면 대략 8~12세트에서 걸린다. 폴백을 켜면 워커당 브라우저 최대 4개로 배수 증가. +- **포트캡** ≈ `DECODO 포트 수 ÷ 3`. 워커마다 다른 sticky 포트가 필요하고 봇 감지 시 다음 포트로 + 회전하므로 회전 여유분이 필수 — 워커 수가 포트 수에 근접하면 회전 후 옆 워커가 쓰던 IP 를 받는 + 충돌이 생긴다. `config` 의 `port_start~port_end`에서 바로 계산. + +**소프트캡(실측 — 보통 이게 진짜 상한)** + +`WORKER_CONCURRENCY` 3→6→9… 계단으로 올리며 매번 같은 부하(`N=30 loadtest.py` 등)를 걸고, +아래 신호 중 **먼저 오는 것**이 그 서버의 상한: + +1. **처리량 정체** — 워커 2배인데 상품/분이 2배가 안 됨(리포트 숫자로 비교). +2. **봇 감지율 급증** — `blocks_1h`가 워커 수보다 가파르게 상승. 프록시 재시도 비용+차단 리스크라 + CPU 보다 먼저 멈춰야 하는 신호. +3. **메모리 압박** — macOS `memory_pressure` / Linux swap 시작. Chrome 은 부족하면 느려지는 게 아니라 크래시. +4. **워커 파이썬 프로세스의 단일 코어 포화** — 워커 N개는 **파이썬 프로세스 1개 안의 asyncio 태스크**라 + 파이썬 쪽 일(파싱·AI 응답 처리·DB)은 코어 1개를 공유한다. 모니터에서 worker 프로세스가 코어 1개 + 기준 100%에 붙으면 그 이상은 무의미. (Chrome 들은 별도 프로세스라 나머지 코어로 퍼진다.) +5. **p95 지연 상승** — 상품당 지연이 워커 늘리기 전보다 나빠지면 경합(락·프록시·DB) 시작. + +한 서버의 실측 상한을 넘는 처리량이 필요하면 워커 컨테이너를 **수평 확장**한다(§1 — 큐 기반이라 +워커만 늘리면 됨. `Dockerfile.worker` + compose). + +## 7. 최저가 이력 (그래프) + +- **트리거 기반**: 자동 배치 없이 **실제 조회된 상품만** 그 시점에 기록 → 트래픽·비용 절약. +- 검색할 때마다 **네이버/쿠팡/최종 최저가** + **몰별 스냅샷(`by_mall`)** 을 남깁니다. +- 그래프: X축 = 조회 시각(불규칙), Y축 = 가격, 3개 선(+몰별). (한쪽 소스에 없던 시점은 선이 비어있음 — 정상) + +## 8. 설계 원칙 (참고) + +- **PostgreSQL을 큐로 제대로 사용**: 별도 브로커 없이 원자적 할당 + 자동 복구로 유실·중복 없이 처리. +- **소스 어댑터 패턴**: 소스별 수집·안티봇 차이를 어댑터 안에 가두고, 코어는 정규화된 결과만 다룸 → 새 쇼핑몰 추가가 쉬움. +- **AI로 매칭**: 상품명 형식이 제각각이라 규칙 고정 파싱 대신 AI가 "같은 상품인지" 판단. +- **오픈마켓은 네이버 폴백**: 네이버가 이미 커버하는 몰은 재크롤하지 않고, 못 덮은 몰만 크롤(비용 절약). + +더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다. diff --git a/lps/docs/database.md b/lps/docs/database.md new file mode 100644 index 0000000..4a4b8c9 --- /dev/null +++ b/lps/docs/database.md @@ -0,0 +1,108 @@ +# 데이터베이스 구조 + +[← 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/gmarket/auction/st11) | +| `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 | +| `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/decision-openmarket-crawler.md b/lps/docs/decision-openmarket-crawler.md new file mode 100644 index 0000000..69975ad --- /dev/null +++ b/lps/docs/decision-openmarket-crawler.md @@ -0,0 +1,68 @@ +# 논의: 오픈마켓 크롤러를 계속 안고 갈까? + +> 상태: **결정 완료(B. 게이트/OFF)** · 작성 2026-07-09 · 결정 2026-07-10 · 대상: 개발팀 +> 한 줄: G마켓·옥션·11번가 **크롤러가 비용·복잡성의 ~80%인데, 실제 값어치(ROI)가 미증명**이라 유지 여부를 정해야 함. + +--- + +## 1. 배경 + +LPS는 상품별 최저가를 찾는다. 소스는 2계층: +- **핵심**: 네이버 쇼핑 API(무료·빠름·안정) + 쿠팡(브라우저 크롤) +- **폴백**: 네이버가 못 덮은 몰만 **G마켓·옥션·11번가 크롤** → 몰별 가격 + +폴백 크롤러는 안티봇(쿠팡 Akamai, **G마켓/옥션 Cloudflare Turnstile**)을 뚫어야 해서 실제 브라우저 + residential 프록시(DECODO)가 필요하다. + +## 2. 현재 데이터 (실측 2026-07) + +| 항목 | 값 | +|------|-----| +| 검색 1건 비용 | **~$0.013** (1,000건 ~$13) | +| 그 중 DECODO(프록시 대역폭) | **~87%** ← 대부분 오픈마켓 크롤 | +| 크롤 몰이 **최종 최저가를 이긴 횟수** | **0** (네이버/쿠팡이 이김) | +| 상품당 지연 | ~35초 (크롤 때문) | +| gmarket 커버리지 | **IP 평판 의존, 플래키** (Turnstile) | + +> 즉 **가장 비싸고·느리고·불안정한 부분이 최종 답을 바꾼 적이 없다.** + +## 3. 크롤러가 주는 가치 (2가지) + +1. **더 싼 가격 발견** — 데이터상 거의 없음(네이버/쿠팡이 최저가) +2. **몰별 검증·분석** — 네이버 보고가 vs 실제 크롤가 일치 확인, 몰별 투명성 + → **진짜 가치는 여기.** 단, **이 데이터를 실제로 소비하는 화면/의사결정이 있어야 의미**가 있음. + +## 4. 핵심 질문 (팀 논의) + +- ❓ **by_mall(몰별 가격) 데이터를 지금 또는 로드맵에서 실제로 쓰는 화면/기능이 있는가?** + - 있다 → 유지 가치 있음 (아래 A) + - 없다 / 막연히 쌓는 중 → 투기적. 소비자 생길 때 켜도 됨 (아래 B) +- ❓ 몰별 검증이 필요하다면 **얼마나 신선해야 하나?** (실시간? 1시간? 하루?) +- ❓ 비용 $13/1,000건 × 예상 검색량 = 월 얼마인가? 감당 범위인가? + +## 5. 옵션 + +| 옵션 | 내용 | 비용/복잡성 | 언제 | +|------|------|------------|------| +| **A. 유지 + TTL 캐시** | 같은 상품 몰별 가격을 창(예 1h)당 1회만 크롤·재사용 | 비용 **대폭↓**(재크롤 회피율만큼), 구현 소(小) | by_mall 소비자가 있고, 분석 가치를 저렴히 유지하고 싶을 때 ← **추천** | +| **B. 게이트/OFF** | 크롤 기본 끔. 네이버 몰별분해(추가요청 0)만 사용. 필요 시 플래그로 켬 | 비용 **~87%↓**, 복잡성↓ | 아직 소비자가 없을 때 | +| **C. gmarket만 드롭** | 가장 플래키·비싼 gmarket 제거, 옥션·11번가 유지 | 부분 절감 | gmarket 부담만 클 때 | +| **D. 현행 유지** | 매 검색 전 몰 크롤 | 비용·불안정 그대로 | 분석 가치가 풀 비용을 정당화할 때 | + +> **왜 curl_cffi(브라우저 없이 쿠키 재사용)로 싸게 못 하나?** 스파이크 결과 쿠팡(Akamai)은 되지만 **G마켓/옥션(Cloudflare)은 cf_clearance가 브라우저 지문에 고정돼 실패**. 즉 비싼 소스는 "크롤을 싸게"가 불가 → 남은 레버는 **"크롤을 덜 하기"(TTL 캐시)**. + +## 6. 참고: 안 바뀌는 것 (크롤러와 무관하게 견고) + +큐(PG 원자적 claim·lease·dead-letter), 어댑터 패턴, AI 같은상품 매칭, 비용 계측(CDP 실측), 다중 병렬, **네이버+쿠팡 핵심 경로**(싸고 빠르고 안정) — 여기는 유지. 이번 결정은 **오픈마켓 크롤러 서브시스템에 한정**된다. + +--- + +## 결론 (2026-07-10 개발자 협의로 확정) + +- [x] **결정: B. 게이트/OFF** — 검색은 **네이버+쿠팡만**. 오픈마켓 폴백 3종은 **코드·테스트 유지, 로직에서 제외(기본 비활성)**. + - 근거: 크롤 몰의 최종 최저가 기여 0회 + 검색당 최대 15s(폴백 데드라인) + 비용의 ~87%(DECODO)가 이 경로. +- [x] 구현: 주석처리가 아닌 **env 토글** — `LPS_FALLBACKS`(기본 빈값=OFF, 예: `gmarket,auction,st11`, 일부만도 가능). + - `worker_main.py` 가 이 값으로만 폴백 어댑터를 생성. 핸들러는 빈 폴백을 원래 정상 처리(`worker/handlers.py`)라 로직 변경 없음. + - 폴백 로직·파서 테스트는 fake 주입이라 **비활성 상태에서도 계속 돈다**(코드 부패 방지). + - by_mall 은 네이버 노출 몰 + 쿠팡으로만 채워짐 → 소비처(프론트) 연동 시 공유할 것. +- [x] 재개 트리거: by_mall 소비 화면이 생기거나, 특정 몰이 네이버 커버리지에서 빠져 가격 검증이 필요해질 때. + - **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `LPS_FALLBACKS` 설정(로컬은 `run_local_worker.sh` 질문, 배포는 compose env) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것. diff --git a/lps/docs/operations.md b/lps/docs/operations.md new file mode 100644 index 0000000..d97104e --- /dev/null +++ b/lps/docs/operations.md @@ -0,0 +1,196 @@ +# 운영 가이드 — 실행 · 로그 · 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]` | 프록시 정보(비워두면 프록시 미사용) | + +> **한 파일에 설정+시크릿 통합** 관리(로컬). **Docker 이미지에는 이 파일이 들어가지 않는다** — +> 빌드 시 `.dockerignore` 로 제외되고 example(플레이스홀더)이 대신 들어가며, 실값은 compose 의 +> env 로 주입한다(리포 루트 `.env`, 템플릿 `.env.example`). `server_configs` 의 env override 가 +> DB 접속·`OPENAI_API_KEY`·`DECODO_*`(포트 포함)·`NAVER_KEYS` 를 모두 덮는다. + +**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 +./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필·폴백 선택 +# 또는 직접: +PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간 +WORKER_CONCURRENCY=3 python worker_main.py # 동시성 2~3(로컬). Chrome 최대 4×N개 +LPS_FALLBACKS=gmarket,auction,st11 python worker_main.py # 오픈마켓 폴백 재가동(기본 OFF — decision 문서 참고) +``` +> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프리플라이트 OK — egress IP ...`, `AI: ON/OFF`가 표시됩니다. +> 동시성 N이면 상품 N개가 진짜 병렬 처리됩니다(각 워커가 자기 프로필·프록시 IP 사용). + +**워커 종료 (graceful)** +- `Ctrl+C`(SIGINT) 또는 `docker stop`(SIGTERM) 1회 → **새 잡은 안 받고, 하던 잡을 마무리한 뒤** 리스너·브라우저를 정리하고 종료합니다(`LPS 워커 종료 완료` 로그, 트레이스백 없음). +- 유예시간 `LPS_SHUTDOWN_GRACE_SEC`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다. +- Docker 는 compose 의 `stop_grace_period: 75s`(유예 60s + 정리 여유)가 SIGKILL 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요. + +**부하 테스트** +```bash +N=8 python loadtest.py # e2e: 상품 8개 제출→처리량·지연(p50/p95)·AI/DECODO/총비용 집계 (워커 필요) +./run_loadtest_gui.sh # API 부하: Locust 웹 UI(:8089)에서 RPS/지연 실시간 관측 (워커 OFF) +``` +> e2e(loadtest.py)는 워커 동시성만큼 병렬 처리됩니다(동시성 낮으면 큐에서 순차 대기 — 그게 부하 관측 포인트). +> API 부하(GUI)는 enqueue/조회 경로만 측정하므로 **워커를 끄고** 실행합니다(실제 크롤 비용 회피). + +## 2-1. 멀티코어 스케일 & 커넥션 풀 (자동) + +API 서버는 asyncio(스레드 1개)라 **1 프로세스 = 1 코어**입니다. 처리량을 코어만큼 올리려면 +`process_count`(uvicorn 워커 수)를 늘립니다 — 이때 **DB 커넥션 풀은 config 가 자동으로 맞춰줍니다**. + +``` +실제 동시 커넥션 = (pool_size + max_overflow) × 2엔진(R/W) × process_count +config 가 보장: 위 값 ≤ connection_budget (기본 40) +``` +- `process_count` 를 올리면 `pool_size/max_overflow` 가 **자동으로 축소**되어 예산을 넘지 않습니다. + (수동 튜닝 불필요 — 예전엔 이걸 안 맞춰서 워커↑ 시 커넥션 고갈→요청 실패가 났음) +- 기동 로그에서 실효값 확인: `DB Pool : pool_size=.. max_overflow=.. × 2engine × Nworkers = M conns (budget=..)` +- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `DB_CONNECTION_BUDGET=90` 으로 상향. +- env 로 조절(코드/toml 수정 없이): `PROCESS_COUNT`, `DB_CONNECTION_BUDGET`, (특수 시)`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`. +- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치). + +## 3. 로그 보는 법 (워커 터미널) + +| 로그 | 의미 | +|------|------| +| `DECODO 프리플라이트 OK — egress IP ...` | 시작 시 살아있는 프록시 포트 선점 성공(egress IP 표시) | +| `[warmup:gmarket] 챌린지 통과·쿠키 확보` | 시작 웜업 — 챌린지 미리 풀어 쿠키 선점(실 작업 웜) | +| `[naver] query='...' → N건` | 네이버 검색 결과 수 | +| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 | +| `[gmarket/auction/st11] query='...' → N건` | 오픈마켓 폴백 크롤 결과 수 | +| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 | +| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → IP 회전 | +| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP | +| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 | +| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) | +| `[worker-0] done ` / `fail ... → DEAD` | 작업 완료 / 실패 | + +> 디버그 로그가 안 보이면 `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, by_mall +FROM price_history WHERE product_code='T1' ORDER BY triggered_at; + +-- 검색 원가(최근 완료 작업의 metrics) +SELECT job_id, + result->'metrics'->'cost'->>'total_usd' AS 총비용, + result->'metrics'->'cost'->>'proxy_usd' AS DECODO, + result->'metrics'->'crawl'->>'proxy_bytes' AS 전송바이트, + result->'metrics'->>'duration_ms' AS 소요ms +FROM job WHERE status=3 ORDER BY updated_at DESC LIMIT 5; + +-- 봇 감지 패턴 (IP당 평균 몇 요청 만에 감지?) +SELECT avg(ip_request_no), count(*) FROM bot_detection; + +-- 네거티브 캐시(없음으로 기록된 상품) +SELECT key, until, reason FROM search_negative ORDER BY created_at DESC; +``` + +## 4-1. 관측·알림 (모니터링) + +| 엔드포인트/신호 | 용도 | +|------|------| +| `GET /healthz` | liveness — 프로세스 살아있는지(DB 무관) | +| `GET /readyz` | readiness — DB 도달성까지 확인(실패 503). LB/오케스트레이터용 | +| `GET /v1/lps/ops` | 운영 스냅샷: 큐 카운트 + `oldest_pending_sec`(큐 지연) + `dead_1h` + `stuck_running` + `blocks_1h`(최근 차단). 외부 모니터가 스크랩·알림 | +| 워커 하트비트 | `/tmp/lps_worker_heartbeat`(mtime) — 컨테이너 HEALTHCHECK 가 신선도<120s 로 행/좀비 워커 감지 | + +**실시간 대시보드(로컬)**: `./run_monitor.sh` → http://localhost:9700 — 큐 추이·처리량(개/분)· +코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률을 2초 간격으로 시각화. +부하테스트/e2e(`N=100 python loadtest.py`) 관측용. 상세는 `loadtest/README.md`. + +**임계 알림**(워커 ops-monitor): 초과 시 WARN 로그 + (env 있으면) Slack 호환 웹훅. +``` +LPS_ALERT_WEBHOOK=https://hooks.slack.com/... # 있으면 알림 전송 +LPS_ALERT_DEAD_1H=20 LPS_ALERT_BLOCKS_1H=80 LPS_ALERT_QUEUE_LAG_SEC=300 +``` + +## 5. 테스트 + +```bash +python -m pytest # 단위·통합(96) — 브라우저/네트워크 불필요 +LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라이브 스모크(셀렉터·안티봇 드리프트 감지) +``` +> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요: +> ```bash +> pkill -f worker_main.py +> ``` +> 라이브 스모크는 IP 의존·느려서 기본 skip. 배포 후 셀렉터가 깨졌는지 수동/야간 점검용. + +## 6. 문제 해결 + +| 증상 | 원인 / 해결 | +|------|------------| +| 포트 9600 사용 중 | `lsof -ti:9600 \| xargs kill` 후 재실행 | +| 백그라운드 실행 시 로그 안 보임 | `print` 버퍼링 → `PYTHONUNBUFFERED=1` 붙여 실행 | +| `프리플라이트 실패`/모든 크롤 실패 | DECODO 프록시 문제 — **대시보드에서 잔여 트래픽·플랜·자격증명** 확인(407=인증거부). 게이트 다운이면 네이버(직접)만 동작 | +| G마켓 결과 계속 0건 | Cloudflare Turnstile 미통과(나쁜 IP는 인터랙티브 체크박스) — 웜업 IP회전 재시도로 완화. 지연 부담이면 폴백 데드라인이 스킵 | +| 쿠팡 `blocked=True`(Access Denied 등) | Akamai 차단 → 자동 IP 회전(감지 이력 `bot_detection`). 반복되면 프록시 IP 풀 확대 | +| Chrome이 계속 쌓임 | 유휴 정리(120s)가 닫음. 스파이크/이전 워커 잔여는 `pkill -f "user-data-dir=/tmp/lps_"` | +| AI 매칭이 0건 자주 발생 | 검색어 모호/스펙 불일치 → `product_name`/`specification`을 더 정확히 | +| 검색이 너무 느림/비쌈 | `result.metrics`로 소스별 시간·DECODO 바이트 확인. 대역폭이 대부분(오픈마켓 크롤) | +| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id | + +## 7. Docker 배포 + +```bash +# 루트에서 (DB 는 외부 PostgreSQL, host.docker.internal 로 연결) +docker compose build lps-api lps-worker +docker compose up -d lps-api lps-worker +docker logs -f lps-worker # 웜업·검색 로그 +docker ps # lps-worker "(healthy)" 확인 +``` +- **워커 = 헤드풀 Chromium + Xvfb**(`Dockerfile.worker`): **headless 는 Akamai·Cloudflare Turnstile 에 탐지됨**(실측). Xvfb 가상 디스플레이로 headful 실행. +- **API = lean**(`Dockerfile`, 브라우저 불필요). +- **시크릿은 이미지에 없음(강제)**: 이미지는 example config 로 빌드된다(`.dockerignore` 가 + config.local.toml·`.profiles/` 제외). 실값은 **리포 루트 `.env`**(템플릿 `.env.example`)에서 + compose env 로 주입. `.env` 없이 뜨면 AI OFF·프록시 미사용으로 조용히 동작하니, 기동 로그의 + `AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것. +- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `LPS_PROFILE_DIR`): 재시작해도 cf_clearance 유지 → 재웜업 회피. +- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를 + 재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체. +- **잡 데드라인**: 잡 1건 300s 상한(`LPS_JOB_DEADLINE_SEC`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함. + +**남은 배포 과제**: API 인증·레이트리밋(비용 남용 방지), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정. +**비용**: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 **TTL 캐시**가 다음 절감 후보. diff --git a/lps/loadtest.py b/lps/loadtest.py new file mode 100644 index 0000000..dbdded2 --- /dev/null +++ b/lps/loadtest.py @@ -0,0 +1,124 @@ +"""LPS 부하 테스트 — 여러 상품을 한 번에 제출하고 처리량·지연·비용을 집계한다. + +여러 상품 동시 검색을 재현한다. 워커 동시성(WORKER_CONCURRENCY)만큼 상품이 병렬 처리된다. + python loadtest.py # 카탈로그 앞 6개 + N=100 python loadtest.py # 상품 100개(loadtest/catalog.json 전체) + BASE=http://localhost:9600 python loadtest.py + +상품셋 = loadtest/catalog.json (100종, 실존 상품): 규격만(식품·생활용품) / 규격+모델(가전·디지털) +/ 이름만 / 회사 포함 / 일부 기준가(price→가격밴드 필터 경로)를 섞어 실제 입력 분포를 재현한다. +N > 카탈로그 크기면 반복 확장. product_code 는 부하테스트 전용 프리픽스(LT###, 매회 유니크라 +dedupe·네거티브캐시에 안 걸림). + +측정: 벽시계 총시간, 처리량(상품/분), 상품별·집계 지연(p50/p95), AI·DECODO·총비용. +워커가 떠 있어야 하고, 워커 동시성이 낮으면 상품들이 큐에서 순차 대기한다(그게 부하의 핵심 관측). +""" + +import asyncio +import json +import os +import pathlib +import time + +import httpx + +BASE = os.environ.get("BASE", "http://localhost:9600") +N = int(os.environ.get("N", "6")) + +_CATALOG = json.loads( + (pathlib.Path(__file__).parent / "loadtest" / "catalog.json").read_text(encoding="utf-8") +) + + +def _products(n): + out = [] + for i in range(n): + base = _CATALOG[i % len(_CATALOG)] + out.append({"product_code": f"LT{i:03d}", "job_type": "batch", **base}) + return out + + +def _composition(products) -> str: + """상품셋 구성 요약 — 어떤 입력 분포로 테스트하는지 리포트에 남긴다.""" + model = sum(1 for p in products if p.get("model")) + spec_only = sum(1 for p in products if p.get("specification") and not p.get("model")) + name_only = sum(1 for p in products if not p.get("specification") and not p.get("model")) + priced = sum(1 for p in products if p.get("price")) + return f"모델포함 {model} · 규격만 {spec_only} · 이름만 {name_only} · 기준가 포함 {priced}" + + +def _pct(values, p): + if not values: + return 0 + s = sorted(values) + k = min(len(s) - 1, int(round((p / 100) * (len(s) - 1)))) + return s[k] + + +async def main(): + products = _products(N) + print(f"■ 부하 테스트: 상품 {N}개 → {BASE} (워커 동시성만큼 병렬 처리)") + print(f" 구성: {_composition(products)}\n") + + async with httpx.AsyncClient(timeout=30) as c: + # 큐/헬스 사전 확인 + try: + stats = (await c.get(f"{BASE}/v1/lps/queue/stats")).json()["counts"] + print(f"시작 큐 상태: {stats}") + except Exception as e: + print(f"API 접속 실패({e}). 서버가 떠 있나요?"); return + + t0 = time.monotonic() + r = (await c.post(f"{BASE}/v1/lps/search", json={"data": products})).json() + jobs = [it["job_id"] for it in r.get("items", []) if it.get("job_id")] + print(f"접수: {len(jobs)}건 (중복 스킵 {N - len(jobs)})\n폴링 중…\n") + + results = {} + while len(results) < len(jobs): + await asyncio.sleep(2) + pend = [j for j in jobs if j not in results] + got = await asyncio.gather(*[c.get(f"{BASE}/v1/lps/jobs/{j}") for j in pend]) + for j, resp in zip(pend, got): + d = resp.json() + if d.get("status") in ("DONE", "DEAD"): + results[j] = d + done = len(results) + print(f"\r 진행 {done}/{len(jobs)} ({time.monotonic()-t0:.0f}s 경과)", end="", flush=True) + + wall = time.monotonic() - t0 + print("\n") + + # 집계 + durs, ai_costs, proxy_costs, totals = [], [], [], [] + found = dead = 0 + print(f"{'상품코드':<8} {'상태':<5} {'결과':<10} {'소요':>6} {'AI$':>9} {'DECODO$':>9} {'총$':>9}") + print("-" * 66) + for j in jobs: + d = results[j] + o = d.get("output") or {} + m = o.get("metrics") or {} + cost = m.get("cost") or {} + code = (o.get("query") or "")[:8] + st = d.get("status", "?") + dur = (m.get("duration_ms") or 0) / 1000 + if st == "DONE": + found += 1 if o.get("outcome") == "found" else 0 + durs.append(dur); ai_costs.append(cost.get("ai_usd", 0)) + proxy_costs.append(cost.get("proxy_usd", 0)); totals.append(cost.get("total_usd", 0)) + else: + dead += 1 + print(f"{code:<8} {st:<5} {o.get('outcome','-'):<10} {dur:>5.1f}s " + f"{cost.get('ai_usd',0):>9.6f} {cost.get('proxy_usd',0):>9.6f} {cost.get('total_usd',0):>9.6f}") + + print("-" * 66) + print(f"\n■ 집계 ({len(jobs)}건, DEAD {dead})") + print(f" 벽시계 총시간 : {wall:.1f}s 처리량: {len(jobs)/wall*60:.1f} 상품/분") + print(f" 상품 지연 : p50 {_pct(durs,50):.1f}s · p95 {_pct(durs,95):.1f}s · max {max(durs) if durs else 0:.1f}s") + print(f" ※ 순차합 대비 병렬: 상품별 소요 합 {sum(durs):.0f}s → 벽시계 {wall:.0f}s (동시성 효과)") + print(f" 비용 합계 : AI ${sum(ai_costs):.5f} + DECODO ${sum(proxy_costs):.5f} = ${sum(totals):.5f}") + print(f" 상품당 평균 비용 : ${(sum(totals)/len(totals)) if totals else 0:.6f}") + print(f" 1,000건 추정 비용: ${(sum(totals)/len(totals)*1000) if totals else 0:.2f}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md new file mode 100644 index 0000000..4a72cb0 --- /dev/null +++ b/lps/loadtest/README.md @@ -0,0 +1,71 @@ +# LPS 부하 테스트 (Locust) + +**대상 = API 서버**(web_main). 워커(브라우저 크롤)는 프록시/브라우저에 처리량이 묶여 Locust 대상이 아니다. +API 는 요청을 받아 `job` 테이블에 적재만 하고 즉시 응답한다(**asyncpg I/O 바운드**, bcrypt 없음). + +## 파일 +- `locustfile.py` — enqueue(POST /search, 고유코드 write) + 조회(jobs/stats/ops/readyz) 가중 부하 +- `bench_multicore.sh` — **PROCESS_COUNT 1→N 자동 비교**(멀티코어 스케일링 측정, 대화형) +- `monitor.py` — **실시간 관측 대시보드**(:9700, `../run_monitor.sh`) — 큐 추이·처리량(개/분)·코어별 CPU· + 프로세스 그룹(worker/api/chrome/postgres) 사용률. e2e(`loadtest.py` N=100 등)와 같이 띄워 + "코어가 다 도는지 / 병목이 어느 층인지"를 본다. Grafana 대체가 아닌 로컬 경량 도구(외부 인프라 없음). + +## 실행 +```bash +# 웹 UI (:8089) +.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600 +# 헤드리스 +.venv/bin/locust -f loadtest/locustfile.py --host http://localhost:9600 --headless --processes 4 -u 1500 -r 150 -t 1m +# 멀티코어 자동 벤치 (PROCESS_COUNT 1/2/4) +LOCUST_PROCESSES=4 ./loadtest/bench_multicore.sh +``` +> ⚠️ **워커는 끄고** 실행(부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 정리: +> `psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;"` +> 부하 중 커넥션 관측: `SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db';` + +## 핵심 발견 (2026-07-09, 11코어 맥 · 1500 users · 20s) + +**1) 멀티코어는 되지만 DB 커넥션 풀이 발목을 잡는다.** +API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `workers`(=`process_count`)를 늘리면 +코어만큼 스케일해야 하는데, **기본 풀(pool_size=10, max_overflow=20)에선 워커를 늘릴수록 오히려 실패 폭증**: + +| workers | RPS | 실패 | 원인 | +|:---:|---:|---:|---| +| 1 | 1,137 | 0 | 정상(단일 코어) | +| 2 | 1,390 | 2,997 | 커넥션 초과 시작 | +| 4 | 1,336 | 10,336 | **커넥션 고갈**(SQLAlchemy pool checkout 실패) | + +원인: **`(pool_size+max_overflow) × 2엔진(R/W) × workers` 가 PG `max_connections`(기본 100)를 초과**. +4워커면 (10+20)×2×4 = **240 > 100** → 워커 3~4가 커넥션 못 받아 요청 실패. + +**2) 풀을 올바르게 잡으면 멀티코어가 제대로 작동한다.** + +| PC=4 설정 | RPS | 실패 | +|---|---:|---:| +| 풀 10/20 (240 요구) | 1,336 | 10,336 | +| **풀 8/4 (96 요구)** | **2,705** | **0** | + +풀만 줄이니 **RPS 2배 + 실패 0**. 즉 코드는 멀티코어를 활용할 수 있고, **막는 건 풀 오버서브스크립션**이다. + +## 튜닝 규칙 (프로덕션) +``` +(pool_size + max_overflow) × 2 × process_count ≤ PG max_connections +``` + +**이 규칙은 이제 config 가 자동으로 지킨다** — `MainDBConfig.connection_budget`(기본 40)을 두면, +기동 시 `process_count` 에 맞춰 `pool_size/max_overflow` 를 역산한다: +`(pool+overflow)×2×process_count ≤ connection_budget`. 워커를 늘려도 커넥션이 예산을 넘지 않는다. +(`server_configs._autosize_pool`. 기동 로그 `DB Pool : ... = N conns (budget=…)` 로 실효값 확인) + +| process_count | 자동 산정(예산 40) | 총 커넥션 | +|:---:|:---:|---:| +| 1 | pool 12 / overflow 8 | 40 | +| 2 | pool 6 / overflow 4 | 40 | +| 4 | pool 3 / overflow 2 | 40 | +| 8 | pool 1 / overflow 1 | 32 | + +- **예산 설정**: 전용 PG(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장. `env DB_CONNECTION_BUDGET`. + (공유 PG 기본 40 은 API + worker + 타 서비스가 100 안에 공존하도록 잡은 안전값 → 처리량보다 안정 우선) +- **override 우선순위**: 명시 `DB_POOL_SIZE`/`DB_MAX_OVERFLOW` > 자동 산정(budget>0) > toml `pool_size/max_overflow`(budget=0) +- 더 큰 처리량이 필요하면: 예산 상향 + **PG `max_connections` 상향** 또는 **pgbouncer**(커넥션 풀러) 도입 +- **부하 한계(이 머신)**: 예산 96(≈max_connections)·4워커 ~2,700 RPS, p95 ~900ms, 실패 0 diff --git a/lps/loadtest/bench_multicore.sh b/lps/loadtest/bench_multicore.sh new file mode 100755 index 0000000..275d354 --- /dev/null +++ b/lps/loadtest/bench_multicore.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# 멀티코어 스케일링 벤치 (대화형). +# PROCESS_COUNT 를 1→N 으로 바꿔가며 API 를 띄우고, 각각 locust 헤드리스로 RPS/지연을 측정해 비교한다. +# API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어 → workers 를 늘리면 코어만큼 스케일해야 정상. +# +# 주의: +# - **워커는 반드시 OFF** (부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓임 → 끝나면 TRUNCATE. +# - 커넥션 한계: (pool_size+max_overflow)×2엔진×workers 가 PG max_connections 를 넘으면 거기서 막힌다. +set -euo pipefail +cd "$(dirname "$0")/.." # lps/ + +VENV=".venv"; PY="$VENV/bin/python"; LOCUST="$VENV/bin/locust" +HOST="http://localhost:9600"; PORT=9600 + +command -v psql >/dev/null || true +[[ -x "$LOCUST" ]] || { echo "[setup] locust 설치..."; "$PY" -m pip install -q locust; } + +CORES=$("$PY" -c "import os;print(os.cpu_count())") +echo "── 멀티코어 부하 벤치 ── (CPU 코어: $CORES)" + +# 워커 실행 중이면 경고 +if pgrep -f worker_main.py >/dev/null; then + echo "[warn] 워커(worker_main.py)가 실행 중입니다 — 부하 중 실제 크롤 비용 발생. 중단 권장:" + read -rp " 워커를 종료할까요? [Y/n]: " k; [[ "${k:-Y}" =~ ^[Nn] ]] || pkill -f worker_main.py || true +fi + +read -rp "동시 유저 수(-u) [200]: " USERS; USERS="${USERS:-200}" +read -rp "spawn rate(-r) [20]: " SPAWN; SPAWN="${SPAWN:-20}" +read -rp "각 단계 지속(-t) [45s]: " DUR; DUR="${DUR:-45s}" +read -rp "PROCESS_COUNT 목록(공백구분) [1 2 4]: " PCS; PCS="${PCS:-1 2 4}" + +# PG max_connections (참고) +MAXC=$(psql -h 127.0.0.1 -U postgres -tAc "SHOW max_connections;" 2>/dev/null || echo "?") +echo "PG max_connections=$MAXC · 풀 추정=(pool+overflow)×2×workers" +echo "" + +RESULTS=() +for PC in $PCS; do + # 기존 API 종료 + lsof -ti:"$PORT" 2>/dev/null | xargs kill 2>/dev/null || true; sleep 1 + echo "▶ PROCESS_COUNT=$PC 로 API 기동..." + PROCESS_COUNT="$PC" APP_ENV=local "$PY" web_main.py > "/tmp/lps_api_pc${PC}.log" 2>&1 & + APIPID=$! + # 기동 대기(헬스) + for _ in $(seq 1 20); do curl -s "$HOST/healthz" >/dev/null 2>&1 && break; sleep 1; done + # locust 헤드리스 (--processes 로 부하 생성기도 멀티프로세스 → locust 가 병목되지 않게) + "$LOCUST" -f loadtest/locustfile.py --host "$HOST" --headless --processes "${LOCUST_PROCESSES:-4}" \ + -u "$USERS" -r "$SPAWN" -t "$DUR" --csv "/tmp/lps_locust_pc${PC}" --only-summary >/dev/null 2>&1 || true + kill "$APIPID" 2>/dev/null || true; sleep 1 + # Aggregated 행 파싱: $10=Req/s $17=95% $3=count $4=fail + read -r RPS P95 CNT FAIL < <(awk -F, '$2=="Aggregated"{print $10, $17, $3, $4}' "/tmp/lps_locust_pc${PC}_stats.csv" 2>/dev/null || echo "0 0 0 0") + RESULTS+=("$PC|${RPS:-0}|${P95:-0}|${CNT:-0}|${FAIL:-0}") + printf " → RPS=%.0f p95=%sms 요청=%s 실패=%s\n\n" "${RPS:-0}" "${P95:-0}" "${CNT:-0}" "${FAIL:-0}" +done + +echo "════════ 결과 (동시유저 $USERS, $DUR) ════════" +printf "%-8s %-10s %-10s %-10s %-8s %-10s\n" "workers" "RPS" "p95(ms)" "요청" "실패" "vs 1코어" +BASE="" +for r in "${RESULTS[@]}"; do + IFS='|' read -r pc rps p95 cnt fail <<< "$r" + [[ -z "$BASE" ]] && BASE="$rps" + SCALE=$("$PY" -c "print(f'{(${rps:-0}/${BASE:-1}):.2f}x')" 2>/dev/null || echo "-") + printf "%-8s %-10.0f %-10s %-10s %-8s %-10s\n" "$pc" "${rps:-0}" "${p95:-0}" "${cnt:-0}" "${fail:-0}" "$SCALE" +done +echo "" +echo "해석: RPS 가 workers 에 비례해 오르면 멀티코어 활용 정상. 어느 지점부터 안 오르고 실패가 늘면" +echo " 거기가 한계 — 보통 DB 커넥션(max_connections=$MAXC) 또는 write 경합. 풀/PG 튜닝 대상." +echo "정리: psql -h 127.0.0.1 -U postgres -d lps_db -c 'TRUNCATE job;' (쌓인 부하 잡 제거)" diff --git a/lps/loadtest/catalog.json b/lps/loadtest/catalog.json new file mode 100644 index 0000000..d7ffbc4 --- /dev/null +++ b/lps/loadtest/catalog.json @@ -0,0 +1,102 @@ +[ + {"product_name": "코카콜라 제로", "specification": "355ml 24캔", "price": "21000"}, + {"product_name": "농심 신라면", "specification": "1박스 40개입", "price": "33000"}, + {"product_name": "오리온 초코파이", "specification": "12개입"}, + {"product_name": "제주 삼다수", "specification": "2L 12개입", "price": "13000"}, + {"product_name": "맥심 모카골드 커피믹스", "specification": "1박스 160개입"}, + {"product_name": "서울우유 멸균우유", "specification": "1L 10팩"}, + {"product_name": "햇반 백미밥", "specification": "210g 24개입"}, + {"product_name": "스팸 클래식", "specification": "200g 10캔"}, + {"product_name": "카누 마일드 아메리카노 미니", "specification": "0.9g 100T"}, + {"product_name": "동원 라이트스탠다드 참치", "specification": "100g 10캔"}, + {"product_name": "크리넥스 데코앤소프트 3겹 화장지", "specification": "27m 30롤", "price": "24000"}, + {"product_name": "다우니 섬유유연제 실내건조", "specification": "8.5L"}, + {"product_name": "리스테린 쿨민트", "specification": "750ml 2개"}, + {"product_name": "페리오 캐비티케어 치약", "specification": "160g 6개"}, + {"product_name": "베베숲 시그니처 블루 물티슈", "specification": "70매 10팩"}, + {"product_name": "백설 하얀설탕", "specification": "3kg"}, + {"product_name": "오뚜기 진라면 매운맛", "specification": "120g 40개입"}, + {"product_name": "롯데 빼빼로 오리지널", "specification": "54g 10갑"}, + {"product_name": "매일 바이오 플레인 요거트", "specification": "450g 4개"}, + {"product_name": "광동 옥수수수염차", "specification": "500ml 20병"}, + {"product_name": "칠성사이다", "specification": "250ml 30캔"}, + {"product_name": "롯데 레쓰비 마일드", "specification": "175ml 30캔"}, + {"product_name": "농심 새우깡", "specification": "90g 20봉"}, + {"product_name": "CJ 비비고 왕교자", "specification": "1.05kg 2개"}, + {"product_name": "풀무원 국산콩 두부", "specification": "300g 4모"}, + {"product_name": "청정원 순창 재래식 된장", "specification": "2kg"}, + {"product_name": "오뚜기 3분 카레 약간매운맛", "specification": "200g 10개"}, + {"product_name": "샘표 진간장 금F3", "specification": "1.7L"}, + {"product_name": "케라시스 러블리 데일리 샴푸", "specification": "980ml 2개"}, + {"product_name": "테크 액체세제 드럼겸용", "specification": "4.5L"}, + {"product_name": "홈스타 맥스프레쉬 곰팡이싹", "specification": "900ml 2개"}, + {"product_name": "3M 스카치브라이트 다목적 수세미", "specification": "10개입"}, + {"product_name": "유한락스 레귤러", "specification": "1L 4개"}, + {"product_name": "깨끗한나라 순수 소프트 3겹", "specification": "30m 30롤"}, + {"product_name": "코멧 일회용 마스크 대형", "specification": "50매"}, + {"product_name": "정식품 베지밀A 담백한맛", "specification": "190ml 24팩"}, + {"product_name": "남양 프렌치카페 카페믹스", "specification": "162T"}, + {"product_name": "크라운 참크래커", "specification": "280g 4개"}, + {"product_name": "삼성전자 갤럭시 버즈2 프로", "model": "SM-R510", "specification": "블루투스 이어폰"}, + {"product_name": "삼성전자 970 EVO Plus NVMe M.2 SSD", "model": "MZ-V7S1T0BW", "specification": "1TB"}, + {"product_name": "삼성전자 T7 포터블 외장 SSD", "model": "MU-PC1T0T", "specification": "1TB"}, + {"product_name": "로지텍 K380 멀티디바이스 블루투스 키보드", "model": "K380", "specification": "무선", "price": "33000"}, + {"product_name": "로지텍 MX Master 3S 무선 마우스", "model": "MX Master 3S"}, + {"product_name": "ipTIME 기가비트 유무선공유기", "model": "A3004T"}, + {"product_name": "티피링크 무선 공유기", "model": "Archer AX53", "specification": "AX3000 Wi-Fi 6"}, + {"product_name": "샤오미 미밴드 8", "model": "Mi Band 8", "specification": "스마트밴드"}, + {"product_name": "애플 에어팟 프로 2세대", "model": "MTJV3KH/A", "specification": "USB-C", "price": "300000"}, + {"product_name": "WD 마이 패스포트 외장하드", "model": "WDBYVG0020BBK", "specification": "2TB"}, + {"product_name": "필립스 1000시리즈 전기면도기", "model": "S1121/41"}, + {"product_name": "샤오미 미지아 전기면도기", "model": "S500"}, + {"product_name": "쿠쿠 6인용 전기압력밥솥", "model": "CRP-P0610FD"}, + {"product_name": "소니 노이즈캔슬링 헤드폰", "model": "WH-1000XM5"}, + {"product_name": "소니 노이즈캔슬링 이어폰", "model": "WF-1000XM5"}, + {"product_name": "벤큐 아이케어 모니터", "model": "GW2480", "specification": "24인치 FHD"}, + {"product_name": "델 프로페셔널 모니터", "model": "P2422H", "specification": "24인치 FHD"}, + {"product_name": "앱코 해커 기계식 키보드", "model": "K660", "specification": "청축"}, + {"product_name": "레고 테크닉 부가티 시론", "model": "42083"}, + {"product_name": "레고 클래식 라지 조립 박스", "model": "10698"}, + {"product_name": "샤오미 공기청정기 4 라이트", "model": "AC-M17-SC"}, + {"product_name": "다이슨 V15 디텍트 무선청소기", "model": "SV22"}, + {"product_name": "보쉬 충전 드릴드라이버", "model": "GSR 12V-15"}, + {"product_name": "고프로 히어로12 블랙", "model": "CHDHX-121"}, + {"product_name": "삼성전자 흑백 레이저프린터", "model": "SL-M2030"}, + {"product_name": "샌디스크 울트라 USB 3.0", "model": "SDCZ48-064G", "specification": "64GB"}, + {"product_name": "다이슨 에어랩"}, + {"product_name": "스탠리 퀜처 H2.0 텀블러"}, + {"product_name": "닌텐도 스위치 OLED"}, + {"product_name": "플레이스테이션5 슬림 디스크 에디션"}, + {"product_name": "애플 매직 마우스"}, + {"product_name": "갤럭시 워치7"}, + {"product_name": "에어팟 맥스"}, + {"product_name": "몰스킨 클래식 노트 라지 하드커버"}, + {"product_name": "라미 사파리 만년필"}, + {"product_name": "킨들 페이퍼화이트"}, + {"product_name": "발뮤다 더 토스터"}, + {"product_name": "네스프레소 버츄오 팝"}, + {"product_name": "마샬 액톤3"}, + {"product_name": "JBL 플립6"}, + {"product_name": "인스타360 X4"}, + {"product_name": "조지루시 스테인리스 보온병"}, + {"product_name": "브리타 정수기 마렐라"}, + {"product_name": "옥소 팝컨테이너"}, + {"product_name": "루메나 무선 선풍기"}, + {"product_name": "크록스 클래식 클로그"}, + {"product_name": "진라면 순한맛", "company": "오뚜기", "specification": "120g 20개입"}, + {"product_name": "초코송이", "company": "오리온", "specification": "36g 10개"}, + {"product_name": "바나나맛우유", "company": "빙그레", "specification": "240ml 8개"}, + {"product_name": "갈아만든배", "company": "해태htb", "specification": "1.5L 12병"}, + {"product_name": "2080 진지발리스 치약", "company": "애경", "specification": "120g 5개"}, + {"product_name": "자연퐁 솔잎 주방세제", "company": "LG생활건강", "specification": "1.2L 2개"}, + {"product_name": "딱풀", "company": "아모스", "specification": "35g 5개"}, + {"product_name": "하이테크C 볼펜", "company": "파이롯트", "specification": "0.4mm 검정"}, + {"product_name": "모나미 153 볼펜", "company": "모나미", "specification": "0.7mm 12자루", "price": "4000"}, + {"product_name": "포스트잇 노트 654", "company": "3M", "specification": "76x76mm 5패드"}, + {"product_name": "곰표 밀가루 중력분", "specification": "1kg"}, + {"product_name": "종가집 포기김치", "specification": "3.3kg"}, + {"product_name": "동서 보리차 티백", "specification": "300g 30T"}, + {"product_name": "락앤락 밀폐용기 세트", "specification": "10P"}, + {"product_name": "델몬트 오렌지 주스", "specification": "1.8L 2병"}, + {"product_name": "니베아 립케어 오리지널", "specification": "4.8g 2개"} +] diff --git a/lps/loadtest/locustfile.py b/lps/loadtest/locustfile.py new file mode 100644 index 0000000..0e933a9 --- /dev/null +++ b/lps/loadtest/locustfile.py @@ -0,0 +1,63 @@ +"""LPS **API 서버** 부하 테스트 (enqueue/조회 경로). + +부하 대상은 API(web_main) 다 — 워커(브라우저 크롤)는 프록시/브라우저에 묶여 처리량이 결정되므로 +Locust 대상이 아니다. API 는 요청을 받아 job 테이블에 적재만 하고 즉시 응답한다(asyncpg I/O 바운드). + +측정 목적 + 1) **멀티코어 활용**: API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn workers(=process_count) + 를 1→N 으로 올리며 RPS 가 스케일하는지 본다. (bench_multicore.sh 가 자동 비교) + 2) **부하 한계**: enqueue 는 job write(+dedupe unique index). 한계는 대개 DB(커넥션 풀·write 경합). + (pool_size+max_overflow)×2엔진×workers 가 PG max_connections 를 넘으면 거기서 막힌다. + +⚠️ **워커는 끄고** 실행하라(부하 중 실제 크롤=프록시/AI 비용). 잡은 PENDING 으로 쌓였다가 끝나면 정리: + psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;" + -- 부하 중 커넥션 관측: SELECT count(*) FROM pg_stat_activity WHERE datname='lps_db'; + +실행 + locust -f loadtest/locustfile.py --host http://localhost:9600 # 웹 UI(:8089) + locust -f loadtest/locustfile.py --host http://localhost:9600 --headless -u 200 -r 20 -t 2m +""" + +import random + +from locust import HttpUser, between, task + + +class LpsApiUser(HttpUser): + # 실제 클라이언트처럼 짧게 쉬며 반복(과도한 wait 없이 API 한계를 본다) + wait_time = between(0.05, 0.3) + + def on_start(self): + self.last_job = None + + @task(6) + def enqueue_search(self): + # 고유 product_code → 실제 INSERT(활성 중복 dedupe 회피). 부하의 핵심 write 경로. + code = f"LOAD-{random.randint(0, 2_000_000_000)}" + body = {"data": [{"product_code": code, "product_name": "부하테스트 상품", + "specification": "1박스", "job_type": "batch"}]} + with self.client.post("/v1/lps/search", json=body, name="POST /search", catch_response=True) as r: + if r.status_code == 200 and r.json().get("accepted", 0) == 1: + self.last_job = (r.json().get("items") or [{}])[0].get("job_id") + r.success() + else: + r.failure(f"{r.status_code} {r.text[:120]}") + + @task(3) + def poll_job(self): # 접수 후 상태 폴링(DB read) + if not self.last_job: + return + with self.client.get(f"/v1/lps/jobs/{self.last_job}", name="GET /jobs/{id}", catch_response=True) as r: + r.success() if r.status_code == 200 else r.failure(f"{r.status_code}") + + @task(1) + def queue_stats(self): + self.client.get("/v1/lps/queue/stats", name="GET /queue/stats") + + @task(1) + def ops(self): + self.client.get("/v1/lps/ops", name="GET /ops") + + @task(1) + def readyz(self): + self.client.get("/readyz", name="GET /readyz") diff --git a/lps/loadtest/monitor.py b/lps/loadtest/monitor.py new file mode 100644 index 0000000..ab946f0 --- /dev/null +++ b/lps/loadtest/monitor.py @@ -0,0 +1,354 @@ +"""LPS 실시간 모니터 — 부하/e2e 실행 중 큐 진행·CPU·병목을 브라우저에서 본다. + +무엇을 보여주나 (2초 샘플링, 브라우저 :9700) + · 큐 흐름 : /v1/lps/ops 폴링 → PENDING/RUNNING/DONE/DEAD 추이 + 처리량(상품/분) + · CPU 코어 : 코어별 사용률 막대 — 멀티코어가 전부 도는지 + · 프로세스 : worker/api/chrome/postgres 그룹별 CPU·메모리 — 병목이 어느 층인지 + · 병목 판독 : RUNNING=동시성인데 코어가 놀면 I/O 바운드(크롤 대기=정상 병목), + chrome CPU 가 치솟으면 렌더 병목, postgres 가 치솟으면 DB 병목 + +실행 (Grafana 대체가 아니라 로컬 1회 측정용 — 외부 인프라 없음) + ./run_monitor.sh # 대화형 + BASE=http://localhost:9600 MONITOR_PORT=9700 python loadtest/monitor.py +""" + +import asyncio +import os +import time +from collections import deque +from contextlib import asynccontextmanager + +import httpx +import psutil +import uvicorn +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, JSONResponse + +BASE = os.environ.get("BASE", "http://localhost:9600") +PORT = int(os.environ.get("MONITOR_PORT", "9700")) +INTERVAL = float(os.environ.get("MONITOR_INTERVAL", "2")) +MAX_SAMPLES = 1800 # 2s × 1800 = 1시간 링버퍼 + +GROUPS = ("worker", "api", "chrome", "postgres") +SAMPLES: deque = deque(maxlen=MAX_SAMPLES) +_proc_cache: dict[int, psutil.Process] = {} # cpu_percent 는 이전 호출과의 간격으로 계산 → 객체 재사용 필수 + + +def _classify_processes() -> dict[int, str]: + """pid → 그룹. python 은 cmdline 으로 판별. uvicorn 멀티프로세스 자식(spawn, cmdline 에 + 파일명 없음)은 부모가 api 면 api 로. chrome 은 사용자의 브라우저와 섞이지 않게 + **조상 체인에 워커가 있는 것(크롤 Chromium)만** 집계한다.""" + cls: dict[int, str] = {} + api_parents: set[int] = set() + worker_pids: set[int] = set() + pythons: list[tuple[int, int]] = [] # (pid, ppid) — 2차 패스(부모 귀속)용 + chromes: list[int] = [] + parent_of: dict[int, int] = {} + for p in psutil.process_iter(attrs=["pid", "ppid", "name"]): + try: + pid, name = p.info["pid"], (p.info["name"] or "").lower() + parent_of[pid] = p.info["ppid"] or 0 + if "postgres" in name: + cls[pid] = "postgres" + elif "chrom" in name: # chrome / chromium / helpers — 귀속은 2차 패스에서 + chromes.append(pid) + elif "python" in name: + cmd = " ".join(p.cmdline()) + if "worker_main.py" in cmd: + cls[pid] = "worker" + worker_pids.add(pid) + elif "web_main.py" in cmd or "router.router" in cmd: + cls[pid] = "api" + api_parents.add(pid) + elif "monitor.py" not in cmd and "locust" not in cmd: + pythons.append((pid, p.info["ppid"])) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + for pid, ppid in pythons: + if ppid in api_parents: + cls[pid] = "api" + for pid in chromes: # 워커(patchright→node→chromium)의 자손만 크롤 브라우저 + cur, hops = pid, 0 + while cur and hops < 12: + if cur in worker_pids: + cls[pid] = "chrome" + break + cur, hops = parent_of.get(cur, 0), hops + 1 + return cls + + +def _sample_cpu() -> dict: + cores = psutil.cpu_percent(percpu=True) + groups = {g: {"cpu": 0.0, "mem": 0, "n": 0} for g in GROUPS} + cls = _classify_processes() + for pid, g in cls.items(): + try: + proc = _proc_cache.get(pid) + if proc is None: + proc = _proc_cache[pid] = psutil.Process(pid) + groups[g]["cpu"] += proc.cpu_percent(None) # 코어 1개=100 기준(멀티코어면 100 초과 가능) + groups[g]["mem"] += proc.memory_info().rss + groups[g]["n"] += 1 + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + for pid in [pid for pid in _proc_cache if pid not in cls]: # 죽은 pid 정리 + _proc_cache.pop(pid, None) + for g in GROUPS: + groups[g]["cpu"] = round(groups[g]["cpu"], 1) + groups[g]["mem"] = int(groups[g]["mem"] / 1048576) # MB + return {"cores": [round(c, 1) for c in cores], "groups": groups} + + +async def _sampler(): + psutil.cpu_percent(percpu=True) # priming — 첫 유효 샘플부터 의미 있는 값 + async with httpx.AsyncClient(timeout=3) as c: + while True: + snap = {"t": round(time.time(), 1), **_sample_cpu(), "ops": None} + try: + r = await c.get(f"{BASE}/v1/lps/ops") + if r.status_code == 200: + snap["ops"] = r.json() + except Exception: + pass # API 죽어 있어도 CPU 샘플은 계속 + SAMPLES.append(snap) + await asyncio.sleep(INTERVAL) + + +@asynccontextmanager +async def _lifespan(app): + task = asyncio.create_task(_sampler()) + yield + task.cancel() + + +app = FastAPI(lifespan=_lifespan) + + +@app.get("/api/series") +async def series(since: float = 0.0): + return JSONResponse({ + "base": BASE, "interval": INTERVAL, "ncores": psutil.cpu_count(), + "samples": [s for s in SAMPLES if s["t"] > since], + }) + + +@app.get("/") +async def index(): + return HTMLResponse(PAGE) + + +# ────────────────────────────────────────────────────────────────────────────── +# 대시보드(단일 페이지, 외부 의존 없음). 다크 고정 — 로컬 관측 도구. +# 팔레트는 dataviz 검증 통과값(다크 서피스 #1a1a19 기준, 라인 끝 직접 라벨로 보조 인코딩). +PAGE = r""" + + +LPS 모니터 + + +

LPS 실시간 모니터

+
대상 … · 2초 샘플링 · 최근 1시간 유지
+ +
+ +
+
+

큐 추이

/v1/lps/ops — 잡 상태별 개수. DONE 이 계단처럼 오르면 정상 소화 중
+
+
+
+
+

프로세스 그룹 CPU

코어 1개=100% 기준(멀티코어면 100 초과). 어느 층이 바쁜지 = 병목 후보
+
+
+
+
+

코어별 사용률 (현재)

막대가 고르게 차면 멀티코어 활용 중, 1~2개만 차면 단일 코어 병목
+
+
+
+

현재 스냅샷 (표)

그래프와 같은 데이터의 수치 뷰
+
+
+
+ + + +""" + +if __name__ == "__main__": + print(f"■ LPS 모니터: http://localhost:{PORT} (대상 API {BASE}, {INTERVAL:.0f}s 샘플링)") + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="warning") diff --git a/lps/migrations/2026-07-09-price_history-by_mall.sql b/lps/migrations/2026-07-09-price_history-by_mall.sql new file mode 100644 index 0000000..53d664b --- /dev/null +++ b/lps/migrations/2026-07-09-price_history-by_mall.sql @@ -0,0 +1,5 @@ +-- price_history 에 몰별 최저가 스냅샷(JSONB) 추가. +-- lps 스키마는 SQLAlchemy create_all 이 단일 소스라, 신규 DB 는 모델로 자동 생성된다. +-- 이 파일은 '이미 만들어진' dev/운영 DB 를 모델과 동기화하기 위한 것(인라인 즉석 ALTER 대신 추적 파일). +-- psql -h 127.0.0.1 -U postgres -d lps_db -f migrations/2026-07-09-price_history-by_mall.sql +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS by_mall JSONB; diff --git a/lps/pytest.ini b/lps/pytest.ini new file mode 100644 index 0000000..85c900a --- /dev/null +++ b/lps/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +asyncio_mode = auto +# DB_SESSION_MNG(싱글톤)의 커넥션 풀이 첫 이벤트 루프에 묶이므로, +# 모든 테스트/픽스처가 단일 session 루프를 공유하게 한다. +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session +testpaths = tests +filterwarnings = + ignore::pytest.PytestUnraisableExceptionWarning diff --git a/lps/requirements-api.txt b/lps/requirements-api.txt new file mode 100644 index 0000000..82caf9d --- /dev/null +++ b/lps/requirements-api.txt @@ -0,0 +1,11 @@ +# LPS API 서버 전용 의존성 — 요청 접수/조회만(크롤 없음). +# 크롤 의존성(patchright·curl_cffi·selectolax·openai)은 워커 전용(requirements.txt). +# web_main import 체인 정적 추적으로 확인(2026-07-10): fastapi/uvicorn/sqlalchemy/pydantic 만 필요. +fastapi +uvicorn[standard] +sqlalchemy>=2.0 +greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 +asyncpg +orjson +pydantic>=2.0 +httpx # (얇음) 향후 API→워커 헬스 프록시 등 대비 유지 diff --git a/lps/requirements.txt b/lps/requirements.txt new file mode 100644 index 0000000..21f7a73 --- /dev/null +++ b/lps/requirements.txt @@ -0,0 +1,19 @@ +fastapi +uvicorn[standard] +sqlalchemy>=2.0 +greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 (일부 환경에서 자동 설치 누락됨) +asyncpg +orjson +pydantic>=2.0 +httpx # 외부 사이트/오픈API(최저가 조회) 호출용 async HTTP 클라이언트 + +# --- 크롤링(LPS) — 안티봇 대응은 어댑터 안에 격리. 트레드밀 대비 버전 pin. --- +curl_cffi==0.15.0 # 쿠팡: TLS/HTTP2 지문 위장(impersonate) async HTTP 클라이언트 +selectolax==0.4.10 # 빠른 C 파서(쿠팡 HTML). bs4 대비 최대 30배 +patchright # 스텔스 Playwright 포크. 쿠팡 Akamai JS 챌린지 통과(실제 Chrome, channel=chrome) + # ※ nodriver 는 Python 3.14 소스인코딩 버그로 미채택 → Patchright 로 대체 + # ※ 실행엔 시스템 Google Chrome 필요(로컬) / 배포 이미지엔 chromium 설치 필요 +openai # AI 유사도 판정(같은 상품 매칭) — [OpenAIConfig].api_key(config.local.toml). structured output 사용 + +# --- 로컬 관측 도구(프로덕션 미사용) --- +psutil # loadtest/monitor.py — 코어별 CPU·프로세스 그룹(worker/api/chrome/postgres) 사용률 샘플링 diff --git a/lps/router/router.py b/lps/router/router.py new file mode 100644 index 0000000..93518e6 --- /dev/null +++ b/lps/router/router.py @@ -0,0 +1,78 @@ +import time +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware + +from common.database.db_session_manager import DB_SESSION_MNG +from common.logger import LOG +from common.utils.gtime import GTime +from config.server_configs import web_server_config +import router.v1.lps.search + +API_SERVER_START_TIME = GTime.UTCStr() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # startup + yield + # shutdown: DB 엔진 커넥션 풀 정리 + await DB_SESSION_MNG.dispose_all() + + +app = FastAPI(title="LPS Api Server", lifespan=lifespan) + +# CORS: config 의 cors_origins 가 있을 때만 적용(브라우저 프론트 호출 허용). +# 명시적 오리진을 쓰므로 allow_credentials=True 가능(쿠키/Authorization 헤더 허용). +if web_server_config.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=web_server_config.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축. +app.add_middleware(GZipMiddleware, minimum_size=1000) + + +@app.middleware("http") +async def log_time(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + elapsed = time.time() - start_time + LOG.d(f"took: {elapsed:.4f} - {request.url.path}") + return response + + +@app.get( + path="/healthz", + summary="헬스체크(liveness)", + description="서버 기동 시각을 반환. 프로세스가 살아있는지만 확인(DB 무관).", + responses={404: {"description": "Not found"}}, +) +async def healthz(): + return API_SERVER_START_TIME + + +@app.get( + path="/readyz", + summary="레디니스(readiness)", + description="DB 도달성까지 확인. 오케스트레이터/LB 가 트래픽 라우팅 여부 판단에 사용. 실패 시 503.", +) +async def readyz(): + from fastapi import Response + from crud.job_crud import JobQueue + try: + await JobQueue().ping() + return {"ready": True} + except Exception as ex: + return Response(content=f'{{"ready": false, "error": "{type(ex).__name__}"}}', + media_type="application/json", status_code=503) + + +# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. +app.include_router(router.v1.lps.search.router) diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py new file mode 100644 index 0000000..4097f2d --- /dev/null +++ b/lps/router/v1/lps/protocol.py @@ -0,0 +1,67 @@ +"""LPS API 요청/응답 프로토콜. 커머스→오투오 검색요청 계약을 미러링한다.""" + +from typing import Optional + +from pydantic import BaseModel, Field + +from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol + + +class SearchItem(BaseModel): + """검색 대상 상품 1건. 규격/모델/제조사는 매칭(향후 AI 유사도) 입력으로 함께 적재한다.""" + + product_code: str = Field(description="상품 식별 코드(커머스 기준). dedupe 키로도 사용") + product_name: str = Field(description="상품명") + job_type: str = Field("manual", description="요청 유형(우선순위): new_product|manual|partner|batch") + model: str = Field("", description="모델명") + specification: str = Field("", description="규격(용량/개입/수량 등)") + company: str = Field("", description="제조사/브랜드") + price: str = Field("", description="현재가(참고, 문자열)") + + +class Req_Search(Req_WebPacketProtocol): + data: list[SearchItem] = Field(description="검색 대상 상품 리스트") + + +class EnqueuedItem(BaseModel): + product_code: str + job_id: Optional[str] = Field(None, description="적재된 잡 ID. 활성 중복이면 None") + duplicated: bool = Field(False, description="활성 중복(PENDING/RUNNING)이라 스킵됐는지") + + +class Res_Search(Res_WebPacketProtocol): + accepted: int = Field(0, description="새로 적재된 잡 수(중복 제외)") + items: list[EnqueuedItem] = Field(default_factory=list) + + +class Res_JobStatus(Res_WebPacketProtocol): + job_id: Optional[str] = None + status: Optional[str] = Field(None, description="JobStatus 이름(PENDING/RUNNING/DONE/DEAD)") + attempts: Optional[int] = None + max_attempts: Optional[int] = None + output: Optional[dict] = Field(None, description="잡 결과(완료 시). 봉투 result 와 구분") + last_error: Optional[str] = None + + +class Res_QueueStats(Res_WebPacketProtocol): + counts: dict[str, int] = Field(default_factory=dict, description="상태별 잡 개수") + + +class PricePoint(BaseModel): + triggered_at: str = Field(description="관측 시각(X축)") + outcome: str + matched_count: Optional[int] = None + naver: Optional[int] = Field(None, description="네이버 최저가") + coupang: Optional[int] = Field(None, description="쿠팡 최저가") + final: Optional[int] = Field(None, description="전체 최저가(Y축)") + final_source: Optional[str] = None + naver_name: Optional[str] = None + naver_url: Optional[str] = None + coupang_name: Optional[str] = None + coupang_url: Optional[str] = None + by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)") + + +class Res_PriceHistory(Res_WebPacketProtocol): + product_code: Optional[str] = None + points: list[PricePoint] = Field(default_factory=list, description="시각 오름차순 스냅샷(그래프용)") diff --git a/lps/router/v1/lps/search.py b/lps/router/v1/lps/search.py new file mode 100644 index 0000000..d7425d9 --- /dev/null +++ b/lps/router/v1/lps/search.py @@ -0,0 +1,61 @@ +"""LPS 검색 API 라우터. 요청 적재(enqueue) + 잡 상태/큐 통계 조회.""" + +from fastapi import APIRouter, Depends + +from fastapi import Query + +from router.v1.validator.dependencies import RemoveNoneResponse +from services.lps_service import LpsService +from router.v1.lps.protocol import Res_JobStatus, Res_PriceHistory, Res_QueueStats, Res_Search, Req_Search + +router = APIRouter(prefix="/v1/lps", tags=["LPS"], responses={404: {"description": "Not found"}}) + + +@router.post( + path="/search", + response_model=Res_Search, + summary="최저가 검색 요청", + description="상품 리스트를 받아 상품별 검색 잡을 큐에 적재한다(product_code 로 활성 중복 방지). 실제 검색은 워커가 비동기 수행.", +) +async def search(req: Req_Search, service: LpsService = Depends()): + return RemoveNoneResponse(await service.submit_search(req.data)) + + +@router.get( + path="/jobs/{job_id}", + response_model=Res_JobStatus, + summary="잡 상태 조회", + description="적재된 검색 잡의 상태/시도횟수/결과를 조회한다.", +) +async def job_status(job_id: str, service: LpsService = Depends()): + return RemoveNoneResponse(await service.get_job(job_id)) + + +@router.get( + path="/queue/stats", + response_model=Res_QueueStats, + summary="큐 상태 카운트", + description="상태별(PENDING/RUNNING/DONE/DEAD) 잡 개수. 관리/모니터링용.", +) +async def queue_stats(service: LpsService = Depends()): + return RemoveNoneResponse(await service.stats()) + + +@router.get( + path="/ops", + summary="운영 스냅샷(모니터링)", + description="큐 카운트 + 큐 지연(oldest_pending_sec) + 최근1h DEAD(dead_1h) + lease만료 stuck + " + "최근1h 차단(blocks_1h). 외부 모니터가 스크랩·임계 알림하기 좋은 플랫 JSON.", +) +async def ops(service: LpsService = Depends()): + return await service.ops() + + +@router.get( + path="/products/{product_code}/history", + response_model=Res_PriceHistory, + summary="최저가 이력(그래프)", + description="상품의 트리거별 최저가 스냅샷(네이버/쿠팡/최종)을 시각 오름차순으로 반환. 가격 시계열 그래프용.", +) +async def price_history(product_code: str, limit: int = Query(100, ge=1, le=1000), service: LpsService = Depends()): + return RemoveNoneResponse(await service.price_history(product_code, limit)) diff --git a/lps/router/v1/validator/dependencies.py b/lps/router/v1/validator/dependencies.py new file mode 100644 index 0000000..83de035 --- /dev/null +++ b/lps/router/v1/validator/dependencies.py @@ -0,0 +1,20 @@ +from typing import Any + +from fastapi.responses import JSONResponse + + +# ---- ResponseNone 처리 ----------------------------------------------------- +# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다. +# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다. +def RemoveNoneValues(obj: Any) -> Any: + if isinstance(obj, dict): + return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None} + if isinstance(obj, list): + return [RemoveNoneValues(v) for v in obj] + return obj + + +def RemoveNoneResponse(obj) -> JSONResponse: + # mode="json": datetime/uuid 등을 JSON-safe 문자열로 변환(표준 JSONResponse 가 직렬화 가능). + # (ORJSONResponse 는 최신 FastAPI 에서 deprecated) + return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json"))) diff --git a/lps/run_loadtest_gui.sh b/lps/run_loadtest_gui.sh new file mode 100755 index 0000000..443d7e1 --- /dev/null +++ b/lps/run_loadtest_gui.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# LPS 부하 테스트 — Locust 웹 UI(GUI) 실행 (대화형). +# 브라우저(:8089)에서 동시 유저 수·spawn rate 를 조절하며 실시간 그래프로 RPS/지연을 본다. +# 대상 = API 서버(web_main, :9600). enqueue/조회 경로에 부하를 준다. +# +# ⚠️ 워커는 끄고 실행하라 — 부하 중 실제 크롤(프록시/AI 비용)이 발생한다. +# 쌓인 부하 잡 정리: psql -h 127.0.0.1 -U postgres -d lps_db -c "TRUNCATE job;" +# +set -euo pipefail +cd "$(dirname "$0")" # lps/ + +VENV=".venv" +PY="$VENV/bin/python" +LOCUST="$VENV/bin/locust" +HOST="http://localhost:9600" +PORT=9600 +WEBUI_PORT=8089 + +# 1) venv + locust 보장 +if [[ ! -d "$VENV" ]]; then + echo "[setup] venv 생성 + 의존성 설치..." + python3 -m venv "$VENV" + "$PY" -m pip install -q --upgrade pip + "$PY" -m pip install -q -r requirements.txt +fi +if [[ ! -x "$LOCUST" ]]; then + echo "[setup] locust 설치..."; "$PY" -m pip install -q locust +fi + +# 2) 워커가 떠 있으면 경고(부하 중 실제 크롤=비용) +if pgrep -f worker_main.py >/dev/null 2>&1; then + echo "[warn] 워커(worker_main.py)가 실행 중입니다 — 부하 중 실제 크롤 비용(프록시/AI)이 발생합니다." + read -rp " 워커를 종료할까요? [Y/n]: " k + [[ "${k:-Y}" =~ ^[Nn] ]] || { pkill -f worker_main.py || true; sleep 1; echo " 워커 종료"; } +fi + +# 3) API 기동 확인 — 없으면 띄울지 물어본다 +API_STARTED="" +if curl -s "$HOST/healthz" >/dev/null 2>&1; then + echo "[info] API 서버 감지됨 ($HOST)" +else + echo "[info] API 서버($HOST)가 응답하지 않습니다." + read -rp " 이 스크립트가 API 를 띄울까요? [Y/n]: " s + if [[ ! "${s:-Y}" =~ ^[Nn] ]]; then + read -rp " 멀티코어 PROCESS_COUNT [1] (풀은 자동 산정): " PC; PC="${PC:-1}" + echo " [run] web_main.py (PROCESS_COUNT=$PC) 백그라운드 기동..." + PROCESS_COUNT="$PC" APP_ENV=local "$PY" web_main.py > /tmp/lps_api_loadtest.log 2>&1 & + API_STARTED=$! + for _ in $(seq 1 20); do curl -s "$HOST/healthz" >/dev/null 2>&1 && break; sleep 1; done + if curl -s "$HOST/healthz" >/dev/null 2>&1; then + echo " API 기동 완료 (로그: /tmp/lps_api_loadtest.log)" + else + echo " [error] API 기동 실패 — /tmp/lps_api_loadtest.log 확인"; kill "$API_STARTED" 2>/dev/null || true; exit 1 + fi + else + echo " [안내] 먼저 다른 터미널에서 ./run_local_server.sh 로 API 를 띄우세요."; exit 1 + fi +fi + +# 4) 부하 생성기 멀티프로세스(locust 자체가 병목되지 않게). 코어 많으면 늘린다. +CORES=$("$PY" -c "import os;print(os.cpu_count())") +read -rp "부하 생성기 프로세스 수 --processes [4] (CPU=$CORES): " LP; LP="${LP:-4}" + +# API 를 이 스크립트가 띄웠으면 종료 시 함께 내린다 +cleanup() { [[ -n "$API_STARTED" ]] && { echo ""; echo "[info] 스크립트가 띄운 API 종료"; kill "$API_STARTED" 2>/dev/null || true; }; } +trap cleanup EXIT + +echo "" +echo "════════════════════════════════════════════════════════" +echo " Locust 웹 UI: http://localhost:$WEBUI_PORT" +echo " 대상 API : $HOST" +echo " → 브라우저에서 users/spawn rate 입력 후 START" +echo " 중단: Ctrl+C · 끝나면 쌓인 잡 정리:" +echo " psql -h 127.0.0.1 -U postgres -d lps_db -c 'TRUNCATE job;'" +echo "════════════════════════════════════════════════════════" +echo "" + +# API 를 이 스크립트가 띄웠으면 exec 하지 않는다(종료 시 trap 으로 API 를 함께 내리기 위해). +if [[ -n "$API_STARTED" ]]; then + "$LOCUST" -f loadtest/locustfile.py --host "$HOST" --web-port "$WEBUI_PORT" --processes "$LP" +else + exec "$LOCUST" -f loadtest/locustfile.py --host "$HOST" --web-port "$WEBUI_PORT" --processes "$LP" +fi diff --git a/lps/run_local_server.sh b/lps/run_local_server.sh new file mode 100755 index 0000000..f851156 --- /dev/null +++ b/lps/run_local_server.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# 로컬 LPS 서버 실행 (대화형). 실행하면 모드를 골라 입력한다. +# 최초 실행 시 venv 생성 + 의존성 설치까지 자동으로 한다. +# +set -euo pipefail +cd "$(dirname "$0")" # lps/ + +VENV=".venv" +PY="$VENV/bin/python" +PORT=9600 + +# 1) venv + 의존성 보장 +if [[ ! -d "$VENV" ]]; then + echo "[setup] venv 생성 + 의존성 설치..." + python3 -m venv "$VENV" + "$PY" -m pip install -q --upgrade pip + "$PY" -m pip install -q -r requirements.txt +fi + +# 2) config 보장 +if [[ ! -f config/config.local.toml ]]; then + echo "[error] config/config.local.toml 이 없습니다. 아래로 생성 후 값을 채우세요:" + echo " cp config/config.local.toml.example config/config.local.toml" + exit 1 +fi + +# 3) 모드 선택 +echo "── 실행 모드 선택 ──" +echo " 1) 일반 실행 (web_main.py)" +echo " 2) 자동 재시작 (uvicorn --reload, 개발용)" +echo " 3) 의존성 재설치" +echo " q) 취소" +read -rp "선택 [1]: " choice +choice="${choice:-1}" + +case "$choice" in + 3) echo "[setup] 의존성 재설치..."; "$PY" -m pip install -q -r requirements.txt; echo "완료"; exit 0 ;; + q|Q) echo "취소합니다."; exit 0 ;; +esac + +# 4) 포트 정리 (이미 떠 있으면 종료) +if lsof -ti:"$PORT" >/dev/null 2>&1; then + echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료" + lsof -ti:"$PORT" | xargs kill 2>/dev/null || true + sleep 1 +fi +export APP_ENV=local + +# 5) 실행 +case "$choice" in + 1) # 멀티코어: API 는 asyncio(단일 스레드)라 프로세스 수 = 사용 코어 수. 커넥션 풀은 예산에서 자동 역산. + cpu_count="$(sysctl -n hw.ncpu 2>/dev/null || echo '?')" + read -rp "프로세스 수 PROCESS_COUNT [${PROCESS_COUNT:-1}] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc + pc="${pc:-${PROCESS_COUNT:-1}}" + export PROCESS_COUNT="$pc" + if [[ "$pc" != "1" ]]; then + read -rp "DB 커넥션 예산 DB_CONNECTION_BUDGET [${DB_CONNECTION_BUDGET:-96}] (전용PG≈90+, 공유PG 40): " budget + export DB_CONNECTION_BUDGET="${budget:-${DB_CONNECTION_BUDGET:-96}}" + fi + echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc}개)" + echo " 기동 로그의 'DB Pool : ... × ${pc}workers ... (budget=...)' 으로 실효 풀 확인" + exec "$PY" web_main.py ;; + 2) echo "[run] uvicorn --reload → http://localhost:$PORT/docs" + exec "$VENV/bin/uvicorn" router.router:app --host 0.0.0.0 --port "$PORT" --reload ;; + *) echo "[error] 알 수 없는 선택: $choice"; exit 1 ;; +esac diff --git a/lps/run_local_worker.sh b/lps/run_local_worker.sh new file mode 100755 index 0000000..f670adc --- /dev/null +++ b/lps/run_local_worker.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# 로컬 LPS 워커 실행 (대화형). 실행하면 동시성 등을 골라 입력한다. +# 최초 실행 시 venv 생성 + 의존성 설치까지 자동으로 한다. +# 워커 = 실제 검색 수행(네이버/쿠팡/오픈마켓 크롤). 실행 시 쿠팡용 Chrome 창이 뜬다(정상). +# +set -euo pipefail +cd "$(dirname "$0")" # lps/ + +VENV=".venv" +PY="$VENV/bin/python" + +# 1) venv + 의존성 보장 +if [[ ! -d "$VENV" ]]; then + echo "[setup] venv 생성 + 의존성 설치..." + python3 -m venv "$VENV" + "$PY" -m pip install -q --upgrade pip + "$PY" -m pip install -q -r requirements.txt +fi + +# 2) config 보장 +if [[ ! -f config/config.local.toml ]]; then + echo "[error] config/config.local.toml 이 없습니다. 아래로 생성 후 값을 채우세요:" + echo " cp config/config.local.toml.example config/config.local.toml" + exit 1 +fi + +# 3) 이미 워커가 떠 있으면 안내 +if pgrep -f worker_main.py >/dev/null 2>&1; then + echo "[info] 이미 워커(worker_main.py)가 실행 중입니다." + read -rp "기존 워커를 종료하고 새로 띄울까요? [y/N]: " kill_old + if [[ "${kill_old:-N}" =~ ^[Yy] ]]; then + pkill -f worker_main.py || true; sleep 1; echo "[info] 기존 워커 종료" + else + echo "취소합니다."; exit 0 + fi +fi + +# 4) 동시성 입력 (상품 동시 검색 수 · 워커별 브라우저 세트 · Chrome 최대 4×N개) +echo "── 워커 설정 ──" +read -rp "동시 검색 수 WORKER_CONCURRENCY [3] (로컬 권장 2~3): " CONC +CONC="${CONC:-3}" + +# 5) Chrome 프로필 영속 디렉터리 (재시작해도 cf_clearance 유지 → 재웜업 회피). 비우면 기본(/tmp) +read -rp "Chrome 프로필 디렉터리 LPS_PROFILE_DIR [.profiles] (엔터=유지): " PROFILE +PROFILE="${PROFILE:-.profiles}" +mkdir -p "$PROFILE" + +# 6) 오픈마켓 폴백 — 기본 비활성(2026-07-10 협의: 최종 최저가 기여 0회, 검색당 최대 15s·프록시 비용의 대부분) +read -rp "오픈마켓 폴백 LPS_FALLBACKS [비활성] (켜려면 예: gmarket,auction,st11): " FALLBACKS +export LPS_FALLBACKS="${FALLBACKS:-}" + +export APP_ENV=local +export WORKER_CONCURRENCY="$CONC" +export LPS_PROFILE_DIR="$PROFILE" +export PYTHONUNBUFFERED=1 # 로그 실시간 출력 + +echo "" +echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE · 폴백=${LPS_FALLBACKS:-OFF})" +echo " 기동 로그의 'DECODO 프리플라이트 OK — egress IP ...' / 'AI: ON/OFF' 확인" +echo " 중단: Ctrl+C" +echo "" +exec "$PY" worker_main.py diff --git a/lps/run_monitor.sh b/lps/run_monitor.sh new file mode 100755 index 0000000..680cf4c --- /dev/null +++ b/lps/run_monitor.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# LPS 실시간 모니터 실행 (대화형) — 부하/e2e 실행 중 큐 진행·코어별 CPU·병목을 브라우저에서 관측. +# loadtest.py(e2e 100건 등)와 같이 띄워두고 보는 용도. Grafana 대체가 아닌 로컬 경량 도구. +# +set -euo pipefail +cd "$(dirname "$0")" # lps/ + +PY=".venv/bin/python" +[[ -x "$PY" ]] || { echo "[error] .venv 가 없습니다. ./run_local_server.sh 를 먼저 한 번 실행하세요."; exit 1; } + +# psutil 보장(관측 도구 전용 의존성) +"$PY" -c "import psutil" 2>/dev/null || { echo "[setup] psutil 설치..."; "$PY" -m pip install -q psutil; } + +echo "── 모니터 설정 ──" +read -rp "모니터 포트 [9700]: " port +port="${port:-9700}" +read -rp "대상 API [http://localhost:9600] (엔터=유지): " base +base="${base:-http://localhost:9600}" + +if lsof -ti:"$port" >/dev/null 2>&1; then + echo "[info] 포트 $port 사용 중 → 기존 프로세스 종료" + lsof -ti:"$port" | xargs kill 2>/dev/null || true + sleep 1 +fi + +echo +echo "[run] 모니터 → http://localhost:$port (대상 $base)" +echo " 워커·API·크롬·postgres 의 CPU 와 큐 추이가 2초마다 갱신됩니다. 중단: Ctrl+C" +echo +exec env BASE="$base" MONITOR_PORT="$port" "$PY" loadtest/monitor.py diff --git a/lps/services/.gitkeep b/lps/services/.gitkeep new file mode 100644 index 0000000..fcaae84 --- /dev/null +++ b/lps/services/.gitkeep @@ -0,0 +1,2 @@ +# services 폴더 placeholder — 도메인 비즈니스 로직(service)을 여기에 추가한다. +# backend/services/*.py 컨벤션: 라우터가 Depends 로 주입받고, DB 접근은 crud + DB_SESSION_MNG 람다로 위임. diff --git a/lps/services/ai/keyword.py b/lps/services/ai/keyword.py new file mode 100644 index 0000000..ed561b5 --- /dev/null +++ b/lps/services/ai/keyword.py @@ -0,0 +1,51 @@ +"""LLM 검색어 생성 — 재정제 라운드용 정밀/광역 쿼리 생성. + +원본 검색어가 0매칭일 때 사용한다: + - precise: 모델명/규격을 반영한 정밀 검색어("스탠리 텀블러" → "스탠리 퀜처 887ml") → 매칭률↑ + - broad: 핵심 상품 명사만 남긴 광역 검색어 → 정밀도 0건이면 폭넓게 최종 확인 +레퍼런스 keyword_maker 의 규칙(모델명 우선, 일반 카테고리어 제거)을 structured output 으로 이식. +""" + +from openai import AsyncOpenAI +from pydantic import BaseModel, Field + +from common.logger import LOG +from config.server_configs import openai_config + +_SYSTEM = ( + "너는 커머스 검색어 생성기다. 상품 정보를 받아 쇼핑몰 검색창에 넣을 한국어 검색어 2개를 만든다.\n" + "- precise: 모델명(있으면 최우선)과 핵심 규격(용량/사이즈/개입 등)을 포함한 정밀 검색어. " + "동일 상품을 정확히 찾기 위함. 일반 카테고리 단어만 나열하지 말 것.\n" + "- broad: 핵심 상품 명사(브랜드+제품군) 위주의 광역 검색어. precise 가 0건일 때 폭넓게 찾기 위함.\n" + "검색어에 따옴표/특수문자/불필요한 수식어를 넣지 말 것." +) + + +class Keywords(BaseModel): + precise: str = Field(description="모델/규격 포함 정밀 검색어") + broad: str = Field(description="핵심 명사 위주 광역 검색어") + + +class KeywordGenerator: + def __init__(self, model: str | None = None, api_key: str | None = None): + self._model = model or openai_config.model + self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) + self.last_usage = None # 직전 호출 토큰 usage(계측용) + + async def generate(self, target: dict) -> Keywords: + user = ( + f"상품명: {target.get('product_name', '')}\n" + f"모델: {target.get('model', '')}\n" + f"규격: {target.get('specification', '')}\n" + f"제조사/브랜드: {target.get('company', '')}" + ) + resp = await self._client.beta.chat.completions.parse( + model=self._model, + messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user}], + response_format=Keywords, + temperature=0, + ) + self.last_usage = getattr(resp, "usage", None) + kw = resp.choices[0].message.parsed or Keywords(precise="", broad="") + LOG.d(f"[ai] 검색어 생성 precise={kw.precise!r} broad={kw.broad!r}") + return kw diff --git a/lps/services/ai/similarity.py b/lps/services/ai/similarity.py new file mode 100644 index 0000000..ea66a3b --- /dev/null +++ b/lps/services/ai/similarity.py @@ -0,0 +1,71 @@ +"""AI 유사도 판정 — 검색 결과가 '찾는 상품과 동일한 상품'인지 OpenAI 로 판별. + +파이프라인(이상치 제거 뒤, top-N 앞) 슬롯에 결합한다. 규칙 고정 파싱이 취약한 문제를 +LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히 다른 규격을 걸러 최저가 오염을 막는다. +structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다. +""" + +from openai import AsyncOpenAI +from pydantic import BaseModel, Field + +from common.logger import LOG +from config.server_configs import openai_config +from services.search.contract import NormalizedProduct + +_SYSTEM = ( + "너는 최저가 비교 시스템의 상품 매칭기다. 검색 결과 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\n" + "규칙:\n" + "- 액세서리·호환부품·부속품(빨대마개·뚜껑·커버·거치대·스트랩·보호필름 등)은 불일치(false).\n" + "- 다른 종류/브랜드/모델은 불일치. 모델명이 주어지면 모델 일치를 우선한다.\n" + "- 용량·규격이 명백히 다르면 불일치.\n" + "- 판매자·색상·포장(개수/박스)·사은품 차이는 동일 상품으로 본다.\n" + "- 확신이 낮으면 score 를 낮게 준다." +) + + +class Judgment(BaseModel): + index: int = Field(description="후보 번호(1부터)") + is_match: bool = Field(description="찾는 상품과 동일 상품이면 true") + score: int = Field(description="동일 확신도 0~100") + + +class JudgmentList(BaseModel): + judgments: list[Judgment] + + +class SimilarityJudge: + def __init__(self, model: str | None = None, api_key: str | None = None): + self._model = model or openai_config.model + self._client = AsyncOpenAI(api_key=api_key or openai_config.api_key) + self.last_usage = None # 직전 호출 토큰 usage(계측용) — 호출부가 await 직후 읽는다 + + async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]: + """후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환.""" + self.last_usage = None + if not candidates: + return [] + + lines = "\n".join(f"{i + 1}. {c.name} ({c.price}원)" for i, c in enumerate(candidates)) + user = ( + f"[찾는 상품]\n" + f"상품명: {target.get('product_name', '')}\n" + f"모델: {target.get('model', '')}\n" + f"규격: {target.get('specification', '')}\n" + f"제조사/브랜드: {target.get('company', '')}\n\n" + f"[검색 결과 후보]\n{lines}\n\n" + f"각 후보가 찾는 상품과 동일 상품인지 index 별로 판별하라." + ) + + resp = await self._client.beta.chat.completions.parse( + model=self._model, + messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user}], + response_format=JudgmentList, + temperature=0, + ) + self.last_usage = getattr(resp, "usage", None) + parsed = resp.choices[0].message.parsed + by_idx = {j.index: j for j in (parsed.judgments if parsed else [])} + # 누락된 후보는 보수적으로 불일치 처리 + out = [by_idx.get(i + 1, Judgment(index=i + 1, is_match=False, score=0)) for i in range(len(candidates))] + LOG.d(f"[ai] 판정 {len(candidates)}건 중 매칭 {sum(1 for j in out if j.is_match)}건") + return out diff --git a/lps/services/lps_service.py b/lps/services/lps_service.py new file mode 100644 index 0000000..71876fb --- /dev/null +++ b/lps/services/lps_service.py @@ -0,0 +1,98 @@ +"""LPS 검색 요청 도메인 로직. 요청을 검색 잡으로 큐에 적재하고 상태/통계를 조회한다. + +라우터는 요청 검증→service 호출→RemoveNoneResponse 만 담당(backend 컨벤션). +실제 검색/크롤링은 워커가 큐에서 잡을 꺼내 파이프라인으로 수행한다(비동기 분리). +""" + +import uuid + +from fastapi import Depends + +from common.enums import ErrorType, JobStatus, JobType +from crud.job_crud import JobQueue +from crud.price_history import PriceHistory +from crud.bot_detection import BotDetectionLog +from router.v1.lps.protocol import ( + EnqueuedItem, + PricePoint, + Res_JobStatus, + Res_PriceHistory, + Res_QueueStats, + Res_Search, + SearchItem, +) + +# 요청 유형 → 우선순위(낮을수록 우선). 알 수 없는 유형은 가장 낮은 우선순위(배치와 동급). +_PRIORITY = {"new_product": 1, "manual": 2, "partner": 3, "batch": 4} + + +class LpsService: + def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory), + bot_log: BotDetectionLog = Depends(BotDetectionLog)): + self.queue = queue + self.history = history + self.bot_log = bot_log + + async def submit_search(self, items: list[SearchItem]) -> Res_Search: + res = Res_Search() + for it in items: + priority = _PRIORITY.get(it.job_type, 4) + job_id = await self.queue.enqueue( + JobType.SEARCH.value, + it.model_dump(), + priority=priority, + dedupe_key=f"search-{it.product_code}", + ) + res.items.append(EnqueuedItem(product_code=it.product_code, job_id=job_id, duplicated=job_id is None)) + res.accepted = sum(1 for i in res.items if i.job_id is not None) + return res + + async def get_job(self, job_id_str: str) -> Res_JobStatus: + res = Res_JobStatus() + try: + uuid.UUID(job_id_str) # 잘못된 id 는 DB 조회 전에 컷 + except (ValueError, TypeError): + res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) + return res + + row = await self.queue.get(job_id_str) + if row is None: + res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) + return res + + res.job_id = row["job_id"] + res.status = JobStatus(row["status"]).name + res.attempts = row["attempts"] + res.max_attempts = row["max_attempts"] + res.output = row.get("result") + res.last_error = row.get("last_error") + return res + + async def stats(self) -> Res_QueueStats: + res = Res_QueueStats() + res.counts = await self.queue.counts() + return res + + async def ops(self) -> dict: + """운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수.""" + snap = await self.queue.ops() + snap["blocks_1h"] = await self.bot_log.recent_count(60) + return snap + + async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory: + res = Res_PriceHistory(product_code=product_code) + rows = await self.history.list_by_product(product_code, limit) + res.points = [ + PricePoint( + triggered_at=r["triggered_at"].isoformat(timespec="seconds"), + outcome=r["outcome"], + matched_count=r["matched_count"], + naver=r["naver_lowest"], coupang=r["coupang_lowest"], final=r["final_lowest"], + final_source=r["final_source"], + naver_name=r["naver_name"], naver_url=r["naver_url"], + coupang_name=r["coupang_name"], coupang_url=r["coupang_url"], + by_mall=r.get("by_mall"), + ) + for r in rows + ] + return res diff --git a/lps/services/metrics.py b/lps/services/metrics.py new file mode 100644 index 0000000..e6c965c --- /dev/null +++ b/lps/services/metrics.py @@ -0,0 +1,90 @@ +"""검색 1건의 리소스/비용/시간 계측(관측용). + +한 상품 검색이 소모하는 것을 잡 단위로 집계한다: + - 시간: 전체 소요 + 소스별 소요(ms) + - AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($) + - 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰 + +핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다. +결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다. + +바이트는 어댑터가 **CDP Network.loadingFinished 의 실제 전송 바이트(encodedDataLength)** 로 측정한다 +(DOM 크기가 아님 → 리소스 차단 효과가 정확히 반영됨). DECODO 는 프록시 경유 바이트로만 과금하므로 +proxy_bytes(=네이버 직접 제외) × cost_per_gb 로 대역폭 비용을 산정한다. +""" + +import time + +# 모델별 1M 토큰당 단가(USD). input=prompt, output=completion. 모르면 0(비용 미추정). +_PRICING = { + "gpt-4o-mini": (0.150, 0.600), + "gpt-4o": (2.50, 10.00), + "gpt-4.1-mini": (0.40, 1.60), +} + + +def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float: + inp, out = _PRICING.get(model, (0.0, 0.0)) + return round(prompt_tokens / 1_000_000 * inp + completion_tokens / 1_000_000 * out, 6) + + +class SearchMetrics: + """검색 1건의 누적 계측기. 스레드/코루틴 공유 X — 잡마다 새로 만든다.""" + + def __init__(self, model: str = "", proxy_cost_per_gb: float = 0.0): + self._start = time.monotonic() + self._model = model + self._proxy_rate = proxy_cost_per_gb # DECODO $/GB + self.ai_calls = 0 + self.ai_prompt = 0 + self.ai_completion = 0 + self.fetches = 0 + self.html_bytes = 0 + self.proxy_bytes = 0 # 프록시(DECODO) 경유 바이트만 — 네이버(직접) 제외 + self.source_ms: dict[str, int] = {} + self.crawled_malls: list[str] = [] + + def add_ai(self, usage): + """OpenAI resp.usage(prompt_tokens/completion_tokens) 누적. None 이면 무시.""" + if not usage: + return + self.ai_calls += 1 + self.ai_prompt += getattr(usage, "prompt_tokens", 0) or 0 + self.ai_completion += getattr(usage, "completion_tokens", 0) or 0 + + def add_fetch(self, source: str, html_bytes: int, ms: int, crawl: bool = False, via_proxy: bool = False): + """외부 fetch 1건(소스·바이트·소요) 누적. crawl=폴백 크롤, via_proxy=DECODO 경유(비용 귀속).""" + self.fetches += 1 + html_bytes = html_bytes or 0 + self.html_bytes += html_bytes + if via_proxy: + self.proxy_bytes += html_bytes + self.source_ms[source] = self.source_ms.get(source, 0) + ms + if crawl and source not in self.crawled_malls: + self.crawled_malls.append(source) + + def snapshot(self) -> dict: + ai_usd = estimate_cost(self._model, self.ai_prompt, self.ai_completion) + proxy_usd = round(self.proxy_bytes / (1024 ** 3) * self._proxy_rate, 6) + return { + "duration_ms": int((time.monotonic() - self._start) * 1000), + "ai": { + "calls": self.ai_calls, + "prompt_tokens": self.ai_prompt, + "completion_tokens": self.ai_completion, + "est_cost_usd": ai_usd, + }, + "crawl": { + "fetches": self.fetches, + "html_bytes": self.html_bytes, + "proxy_bytes": self.proxy_bytes, + "malls_crawled": self.crawled_malls, + }, + # 컴포넌트별 비용($) + 총합. proxy=DECODO 대역폭(근사=처리 바이트, 하한). + "cost": { + "ai_usd": ai_usd, + "proxy_usd": proxy_usd, + "total_usd": round(ai_usd + proxy_usd, 6), + }, + "source_ms": self.source_ms, + } diff --git a/lps/services/pipeline/core.py b/lps/services/pipeline/core.py new file mode 100644 index 0000000..0db04d9 --- /dev/null +++ b/lps/services/pipeline/core.py @@ -0,0 +1,103 @@ +"""최저가 코어 파이프라인. + +검색 결과(NormalizedProduct[]) → 필터 → 이상치 제거 → 정렬 → top-N 최저가. +각 STAGE 의 in/out 건수를 기록해 관측성을 확보한다(레퍼런스 pipeline_log 패턴 계승). + +AI 유사도 판정(같은 상품인지)은 키 준비 시 이 사이(이상치 제거 뒤, top-N 앞)에 끼운다 — +지금은 슬롯만 비워두고 규칙 매칭을 하지 않는다(고정 파서 취약성 회피). +""" + +from services.search.contract import NormalizedProduct +from services.pipeline.filters import keep_only_mall, filter_out_malls, filter_by_price_band +from services.pipeline.outliers import remove_price_outliers + + +def apply_filters( + products: list[NormalizedProduct], + *, + base_price: int | None = None, + keep_mall: str | None = None, + banned_malls=(), + band_tolerance: float = 0.7, + remove_outliers: bool = True, +) -> tuple[list[NormalizedProduct], list[dict]]: + """mall → 가격밴드 → 이상치(IQR) 순으로 후보를 좁힌다. (후보, STAGE로그) 반환. + AI 유사도 판정은 이 뒤(top-N 앞)에 결합한다.""" + stages: list[dict] = [] + cur = list(products) + + def stage(name: str, before: list, after: list): + stages.append({"stage": name, "in": len(before), "out": len(after)}) + + if keep_mall: + before = cur + cur = keep_only_mall(cur, keep_mall) + stage("keep_mall", before, cur) + elif banned_malls: + before = cur + cur = filter_out_malls(cur, banned_malls) + stage("filter_out_malls", before, cur) + + if base_price: + before = cur + cur = filter_by_price_band(cur, base_price, band_tolerance) + stage("price_band", before, cur) + + if remove_outliers: + before = cur + cur, _ = remove_price_outliers(cur) + stage("outlier", before, cur) + + return cur, stages + + +def summarize_by_mall(products: list[NormalizedProduct]) -> list[dict]: + """같은상품 매칭 후보를 판매몰별 최저가로 분해한다(가격 오름차순). + 네이버 결과엔 G마켓·옥션·11번가 등이 mall_name 으로 이미 들어오고, 크롤 폴백도 같은 몰명을 쓴다. + **몰명(canonical)으로 dedup** — 같은 몰이 네이버 노출과 직접 크롤 양쪽에서 와도 최저가 1건으로 병합 + (네이버 가격비교 mall_name='네이버'는 여러 판매자 최저가 롤업이라 별도 몰로 취급).""" + best: dict[str, NormalizedProduct] = {} + for p in products: + key = p.mall_name or p.source or "기타" # 몰 정체성 = 몰명(소스 무관 병합) + cur = best.get(key) + if cur is None or p.price < cur.price: + best[key] = p + rows = sorted(best.values(), key=lambda p: p.price) + return [{ + "source": p.source, "mall_name": p.mall_name, "price": p.price, + "shipping_fee": p.shipping_fee, "shipping_type": p.shipping_type, + "name": p.name, "detail_url": p.detail_url, + } for p in rows] + + +def rank_result(products: list[NormalizedProduct], total_found: int, stages: list[dict], top_n: int = 5) -> dict: + """최저가순 정렬 + top-N + 결과 봉투 조립(top_n STAGE 포함).""" + ranked = sorted(products, key=lambda p: p.price) + top = ranked[:top_n] + stages = stages + [{"stage": "top_n", "in": len(ranked), "out": len(top)}] + return { + "total_found": total_found, + "kept": len(ranked), + "lowest": top[0].model_dump() if top else None, + "top": [p.model_dump() for p in top], + "by_mall": summarize_by_mall(ranked), + "stages": stages, + } + + +def run_price_pipeline( + products: list[NormalizedProduct], + *, + base_price: int | None = None, + keep_mall: str | None = None, + banned_malls=(), + band_tolerance: float = 0.7, + remove_outliers: bool = True, + top_n: int = 5, +) -> dict: + """AI 없는 동기 파이프라인(테스트/기본 경로). apply_filters + rank_result 조합.""" + cur, stages = apply_filters( + products, base_price=base_price, keep_mall=keep_mall, + banned_malls=banned_malls, band_tolerance=band_tolerance, remove_outliers=remove_outliers, + ) + return rank_result(cur, len(products), stages, top_n) diff --git a/lps/services/pipeline/filters.py b/lps/services/pipeline/filters.py new file mode 100644 index 0000000..00b4969 --- /dev/null +++ b/lps/services/pipeline/filters.py @@ -0,0 +1,26 @@ +"""가격 파이프라인 순수 필터(부작용 없음). 레퍼런스의 mall/price-band 필터를 NormalizedProduct 로 이식.""" + +from services.search.contract import NormalizedProduct + + +def keep_only_mall(products: list[NormalizedProduct], mall_name: str) -> list[NormalizedProduct]: + target = (mall_name or "").strip().lower() + return [p for p in products if (p.mall_name or "").strip().lower() == target] + + +def filter_out_malls(products: list[NormalizedProduct], banned) -> list[NormalizedProduct]: + """banned mall(문자열/리스트) 제거. 네이버 결과에서 오픈마켓을 걷어낼 때 쓴다(쿠팡 단독이면 no-op).""" + names = [banned] if isinstance(banned, str) else list(banned or []) + ban = {m.strip().lower() for m in names if m} + if not ban: + return list(products) + return [p for p in products if (p.mall_name or "").strip().lower() not in ban] + + +def filter_by_price_band(products: list[NormalizedProduct], base_price: int, tolerance: float = 0.7) -> list[NormalizedProduct]: + """기준가 ±(tolerance×100)% 밴드만 남긴다. base_price 가 없거나 0 이하면 무필터 통과. + 요청의 현재가(price)를 기준으로 '엉뚱한 저가/고가'(액세서리·묶음 등)를 targeted 하게 컷.""" + if not base_price or base_price <= 0: + return list(products) + lo, hi = base_price * (1 - tolerance), base_price * (1 + tolerance) + return [p for p in products if p.price is not None and lo <= p.price <= hi] diff --git a/lps/services/pipeline/outliers.py b/lps/services/pipeline/outliers.py new file mode 100644 index 0000000..354bd52 --- /dev/null +++ b/lps/services/pipeline/outliers.py @@ -0,0 +1,32 @@ +"""가격 이상치 제거. + +레퍼런스는 반복 z-score(정규분포 가정)를 썼으나, 가격 분포는 한쪽으로 치우친(로그정규) 경우가 +많아 취약하다. 대신 분포 가정이 없는 **IQR(사분위 범위) 방식**을 쓴다(stdlib 만, numpy 불필요). +목적: '엉뚱한 초저가(액세서리/오매칭)·초고가(묶음)'가 최저가 산정을 오염시키는 것을 막는다. +""" + +import statistics + +from services.search.contract import NormalizedProduct + + +def remove_price_outliers( + products: list[NormalizedProduct], k: float = 1.5, min_items: int = 4 +) -> tuple[list[NormalizedProduct], list[NormalizedProduct]]: + """IQR 기반 이상치 제거. [Q1 - k·IQR, Q3 + k·IQR] 밖을 제거. + 데이터가 min_items 미만이면 통계가 무의미하므로 그대로 통과. (kept, removed) 반환.""" + prices = [p.price for p in products if p.price and p.price > 0] + if len(prices) < min_items: + return list(products), [] + + q1, _, q3 = statistics.quantiles(prices, n=4) + iqr = q3 - q1 + lo, hi = q1 - k * iqr, q3 + k * iqr + + kept, removed = [], [] + for p in products: + if p.price is None or lo <= p.price <= hi: + kept.append(p) + else: + removed.append(p) + return kept, removed diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py new file mode 100644 index 0000000..f2842b5 --- /dev/null +++ b/lps/services/search/browser_base.py @@ -0,0 +1,290 @@ +"""브라우저 기반 검색 어댑터 공통 베이스. + +쿠팡·G마켓·옥션·11번가처럼 안티봇(Akamai/ESM 챌린지 등) 때문에 실제 Chrome(patchright)로 +뚫어야 하는 소스의 공통 machinery 를 모은다: + 브라우저 수명 관리 · 프록시 sticky 회전 · 리소스 차단(대역폭↓) · 차단 감지 + IP 회전 재시도 + 감지 기록. + +사이트별 차이는 훅으로 분리한다: + _search_url(query, limit) 검색 URL + _parse(html) HTML → NormalizedProduct[] + ready_selector 결과 렌더 완료 신호 셀렉터(챌린지 통과 대기용) + block_markers/min_result_html 차단 판정(0건일 때) + +detect_block 은 순수 함수로 분리 — 브라우저 없이 단위 테스트 가능. +""" + +import asyncio +import os +import time +from abc import abstractmethod + +from patchright.async_api import async_playwright + +from common.logger import LOG +from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth +from services.search.rate_limiter import RateLimiter + +# 대역폭 절감 기본 차단 집합(쿠팡): 이미지/미디어/폰트/CSS. 쿠팡은 CSS 없이도 파싱·Akamai 통과 OK. +# 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override). +_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"} + +# 브라우저 실행 대상(env override): 로컬 Mac=실제 Chrome(channel=chrome), 컨테이너=시스템 chromium(executable_path). +# headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패). +_CHROME_CHANNEL = os.environ.get("LPS_CHROME_CHANNEL", "chrome") +_CHROME_EXECUTABLE = os.environ.get("LPS_CHROME_EXECUTABLE") or None + + +def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None: + """0건 응답의 차단 여부 판정(순수 함수). 반환: 차단 마커(차단) 또는 None(정상 빈결과). + 상품이 있으면 항상 None. 알려진 마커 우선, 없으면 비정상적으로 짧은 HTML 을 미지의 차단으로 폴백.""" + if product_count > 0: + return None + marker = next((m for m in markers if m in html), None) + if marker is None and len(html) < min_len: + marker = f"short_html({len(html)}B)" + return marker + + +# 프록시 전송 실패 마커 — 사이트 차단이 아니라 DECODO 포트/IP 사망·세션만료. 봇 감지와 별개로 IP 회전 트리거. +_PROXY_ERR_MARKERS = ( + "ERR_TUNNEL_CONNECTION_FAILED", "ERR_PROXY_CONNECTION_FAILED", "ERR_HTTP_RESPONSE_CODE_FAILURE", + "ERR_NO_SUPPORTED_PROXIES", "ERR_SOCKS_CONNECTION_FAILED", "ERR_CONNECTION_CLOSED", + "Proxy Authentication", "status code 407", "407 ", +) + + +def is_proxy_error(msg: str) -> bool: + """예외 메시지가 프록시 전송 실패(포트/IP 사망·407)인지(순수 함수, 단위 테스트 가능). + True 면 사이트 차단이 아니라 프록시 문제 → 다른 IP 로 회전하면 회복 가능.""" + return any(m in (msg or "") for m in _PROXY_ERR_MARKERS) + + +class BrowserSearchAdapter(SearchAdapter): + """patchright(스텔스 Chrome) 기반 검색 어댑터 베이스. persistent context 로 브라우저를 재사용한다.""" + + # 서브클래스 오버라이드 지점 + block_markers: tuple = () + min_result_html: int = 10000 + ready_selector: str = "body" + ready_timeout_ms: int = 20000 + block_resources_default: bool = True # 리소스 차단 라우팅 on/off 기본값. + blocked_resource_types: set = _BLOCKED_RESOURCES # 차단할 resource_type(사이트별 override — 오픈마켓은 CSS/JS 유지) + scroll_steps: int = 0 # >0 이면 렌더 대기 전 스크롤(지연 로딩 트리거, 예: 11번가) + max_proxy_retries: int = 2 # 프록시 전송오류(포트 사망) 시 IP 회전 재시도 횟수 + + def __init__(self, headless: bool = False, user_data_dir: str | None = None, rate_limiter: RateLimiter | None = None, + proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1): + self._headless = headless + self._user_data_dir = user_data_dir or f"/tmp/lps_{self.source}_profile" + self._rl = rate_limiter or RateLimiter() + self._proxy = proxy # DecodoProxy 등 (없으면 직접 연결) + self._block_resources = self.block_resources_default if block_resources is None else block_resources + self._block_active = self._block_resources # 요청별 실제 차단 여부(_blocking_now 로 갱신) + self._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택) + self._max_block_retries = max_block_retries + self._pw = None + self._ctx = None + self._launched_at = 0.0 + self._ip_requests = 0 # 현재 브라우저(IP)로 보낸 요청 수(재기동 시 리셋) + self._current_port = None + self._force_recycle = False + self._lock = asyncio.Lock() + self._ok = 0 + self._blocked = 0 + self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용 + self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백 + self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적) + self.last_bytes = 0 # 직전 search 의 전송 바이트(계측용) — 호출부가 await 직후 읽는다 + + # ---- 사이트별 훅 -------------------------------------------------- + @abstractmethod + def _search_url(self, query: str, limit: int) -> str: + raise NotImplementedError + + @abstractmethod + def _parse(self, html: str) -> list[NormalizedProduct]: + raise NotImplementedError + + async def _wait_ready(self, page): + """결과 렌더 대기. 지연 로딩(scroll_steps>0)이면 먼저 스크롤로 트리거하고, ready_selector 등장까지 대기. + 챌린지형(ESM '잠시만')은 이 대기 시간 안에 자동 통과(IP 평판 좋을 때). 타임아웃은 예외로 두지 않는다 + — 이후 parse 0건이면 차단 판정 로직(마커/짧은HTML)이 처리해 IP 회전을 유도.""" + for _ in range(self.scroll_steps): + try: + await page.evaluate("window.scrollBy(0, 1500)") + except Exception: + break + await page.wait_for_timeout(1000) + try: + await page.wait_for_selector(self.ready_selector, timeout=self.ready_timeout_ms) + except Exception: + pass + + # ---- 공통 브라우저 수명 ------------------------------------------- + async def _route(self, route): + if self._block_active and route.request.resource_type in self.blocked_resource_types: + await route.abort() + else: + await route.continue_() + + async def _blocking_now(self) -> bool: + """이번 요청에서 리소스를 실제로 차단할지. 기본은 설정값 그대로. + ESM(Turnstile)은 챌린지 solving 중엔 차단하면 안 되므로 override(웜=cf_clearance 있으면만 차단).""" + return self._block_resources + + def _recycle_due(self) -> bool: + if self._force_recycle: + return True + if not (self._proxy and self._proxy.enabled): + return False + return (time.monotonic() - self._launched_at) > self._proxy.session_minutes * 60 + + async def _ensure_browser(self): + if self._ctx is not None: + if self._recycle_due(): + LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)") + await self._close_ctx() + else: + return + if self._pw is None: + self._pw = await async_playwright().start() + kwargs = dict(user_data_dir=self._user_data_dir, headless=self._headless, no_viewport=True) + if _CHROME_EXECUTABLE: + kwargs["executable_path"] = _CHROME_EXECUTABLE # 컨테이너: 시스템 chromium + # 컨테이너(root)에선 sandbox 불가 → --no-sandbox 필수(없으면 런칭 행). /dev/shm 부족 크래시 방지. + kwargs["args"] = ["--no-sandbox", "--disable-dev-shm-usage"] + else: + kwargs["channel"] = _CHROME_CHANNEL # 로컬: 실제 Chrome + if self._proxy and self._proxy.enabled: + kwargs["proxy"] = self._proxy.playwright_proxy() + self._current_port = self._proxy.current_port + self._ctx = await self._pw.chromium.launch_persistent_context(**kwargs) + if self._block_resources: + await self._ctx.route("**/*", self._route) + self._launched_at = time.monotonic() + self._ip_requests = 0 + self._force_recycle = False + + async def _close_ctx(self): + self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착 + if self._ctx is not None: + try: + await self._ctx.close() + finally: + self._ctx = None + + def _add_net(self, event): + """CDP Network.loadingFinished 콜백 — 실제 전송 바이트(encodedDataLength) 누적.""" + try: + self._net_bytes += int(event.get("encodedDataLength", 0) or 0) + except Exception: + pass + + async def _ensure_net_meter(self, page): + """CDP 네트워크 계측 세션 부착(컨텍스트당 1회). 실패(미지원)하면 DOM 크기로 폴백.""" + if self._cdp is not None: + return + try: + self._cdp = await self._ctx.new_cdp_session(page) + await self._cdp.send("Network.enable") + self._cdp.on("Network.loadingFinished", self._add_net) + except Exception: + self._cdp = None + + @property + def uses_proxy(self) -> bool: + """이 어댑터가 프록시(DECODO)를 경유하는지 — 대역폭 비용 귀속용.""" + return bool(self._proxy and self._proxy.enabled) + + def _rotate_ip(self, reason: str): + """즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.""" + if self._proxy and self._proxy.enabled: + self._proxy.rotate() + self._force_recycle = True + LOG.w(f"[{self.source}] IP 회전 — {reason}") + + # ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) -------- + async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]: + async with self._lock: # 인스턴스 내 검색 직렬화(브라우저 컨텍스트 공유) + self._last_used = time.monotonic() + proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries + while True: + await self._rl.wait() + await self._ensure_browser() + self._ip_requests += 1 + page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page() + url = self._search_url(query, limit) + self._block_active = await self._blocking_now() # 챌린지 solving 중이면 차단 해제(Turnstile 보호) + await self._ensure_net_meter(page) + self._net_bytes = 0 # 이 검색의 전송 바이트만 집계 + try: + await page.goto(url, wait_until="domcontentloaded", timeout=40000) + await self._wait_ready(page) + html = await page.content() + except Exception as ex: + # 프록시 전송 실패(포트/IP 사망·407)면 사이트 문제가 아니므로 IP 회전 후 재시도 + if self.uses_proxy and is_proxy_error(str(ex)) and proxy_retries > 0: + proxy_retries -= 1 + self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}") + continue + raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex + + # 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백 + self.last_bytes = self._net_bytes if self._cdp is not None else len(html.encode("utf-8")) + products = self._parse(html) + if products: + self._ok += 1 + LOG.d(f"[{self.source}] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})") + return products[:limit] + + marker = detect_block(html, len(products), self.block_markers, self.min_result_html) + blocked = marker is not None + self._blocked += 1 + if blocked: + await self._report_detection(query, marker, len(html)) + + if blocked and self.uses_proxy and block_retries > 0: + block_retries -= 1 + self._rotate_ip(f"봇 감지 재시도 {self._max_block_retries - block_retries}/{self._max_block_retries}") + continue + + raise AdapterError(f"{self.source} 결과 없음/차단 (query={query!r}, blocked={blocked})", source=self.source, blocked=blocked) + + async def _report_detection(self, query: str, marker: str, html_len: int): + elapsed = int(time.monotonic() - self._launched_at) + LOG.w(f"[{self.source}][BOT-DETECTED] ip_req#{self._ip_requests} port={self._current_port} " + f"elapsed={elapsed}s headless={self._headless} marker={marker!r} query={query!r} html_len={html_len}") + if self._on_detect is not None: + event = {"source": self.source, "query": query, "ip_request_no": self._ip_requests, + "proxy_port": self._current_port, "elapsed_sec": elapsed, "marker": marker, + "headless": self._headless, "html_len": html_len} + try: + await self._on_detect(event) + except Exception as ex: + LOG.e_no_callstack(f"[{self.source}] 감지 기록 실패(무시): {ex}") + + async def health(self) -> AdapterHealth: + total = self._ok + self._blocked + rate = (self._ok / total) if total else 0.0 + return AdapterHealth(source=self.source, ok=(self._blocked == 0 or rate > 0.5), + recent_success_rate=rate, blocked_rate=(self._blocked / total) if total else 0.0) + + async def close_if_idle(self, idle_sec: float): + """일정 시간 검색이 없으면 브라우저 컨텍스트를 정리(메모리 회수). playwright 는 유지 — + 다음 검색 때 재기동한다. cf_clearance 등 쿠키는 user_data_dir 에 남아 재기동해도 웜 유지.""" + if self._ctx is None or self._lock.locked(): # 검색 중이면 건너뜀 + return + if time.monotonic() - self._last_used < idle_sec: + return + async with self._lock: + if self._ctx is not None and time.monotonic() - self._last_used >= idle_sec: + LOG.d(f"[{self.source}] 유휴 {idle_sec:.0f}s 초과 → 브라우저 정리(다음 검색 때 재기동)") + await self._close_ctx() + + async def close(self): + if self._ctx is not None: + await self._ctx.close() + self._ctx = None + if self._pw is not None: + await self._pw.stop() + self._pw = None diff --git a/lps/services/search/card_parser.py b/lps/services/search/card_parser.py new file mode 100644 index 0000000..2dbf8c8 --- /dev/null +++ b/lps/services/search/card_parser.py @@ -0,0 +1,74 @@ +"""오픈마켓 검색결과 공용 카드 파서(순수 함수, selectolax). + +G마켓·옥션·11번가처럼 '카드 목록 → 카드별 이름/가격/링크/배송' 구조가 같은 소스를 하나로 파싱한다. +사이트별 셀렉터·소스명·베이스URL 만 주입한다. 브라우저/네트워크 무관 — 저장 HTML 로 단위 테스트 가능. +""" + +from dataclasses import dataclass +from urllib.parse import urljoin + +from selectolax.parser import HTMLParser + +from services.search.contract import NormalizedProduct +from services.search.util import extract_price, clean_name, shipping_from_text + +# 소스 코드 → 표시 몰명(canonical). 폴백 커버리지 판정·dedup 에 쓴다. +MALL_BY_SOURCE = {"naver": "네이버", "coupang": "쿠팡", "gmarket": "G마켓", "auction": "옥션", "st11": "11번가"} + + +def canonical_mall(product) -> str: + """상품의 판매몰 정체성(몰명 우선, 없으면 소스 매핑).""" + return product.mall_name or MALL_BY_SOURCE.get(product.source, product.source) + + +@dataclass(frozen=True) +class CardConfig: + source: str # naver 외 크롤 소스 코드 (gmarket|auction|st11) + mall_name: str # 표시 몰명 (G마켓|옥션|11번가) + base_url: str # 상대경로 링크 절대화용 + card: str + name: str + price: str + link: str = "a[href]" + + +def parse_cards(html: str, cfg: CardConfig) -> list[NormalizedProduct]: + tree = HTMLParser(html) + products: list[NormalizedProduct] = [] + seen: set[str] = set() + + for card in tree.css(cfg.card): + name_el = card.css_first(cfg.name) + name = clean_name(name_el.text(strip=True)) if name_el else None + if not name: + continue + + price_el = card.css_first(cfg.price) + price = extract_price(price_el.text(strip=True)) if price_el else None + if price is None or price <= 0: + continue # 광고/헤더/추천 슬롯 등 유효 상품 아님 + + a = card.css_first(cfg.link) + href = a.attributes.get("href") if a else None + detail_url = urljoin(cfg.base_url, href) if href else None + + key = detail_url or name # 동일 링크/광고 반복 제거 + if key in seen: + continue + seen.add(key) + + shipping_fee, shipping_type = shipping_from_text(card.text(separator=" "), name) + + products.append( + NormalizedProduct( + source=cfg.source, + name=name, + price=price, + detail_url=detail_url, + mall_name=cfg.mall_name, + shipping_fee=shipping_fee, + shipping_type=shipping_type, + ) + ) + + return products diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py new file mode 100644 index 0000000..69eec9b --- /dev/null +++ b/lps/services/search/contract.py @@ -0,0 +1,61 @@ +"""소스 어댑터 공통 계약. + +크롤링/검색 소스(네이버·쿠팡 …)는 각자 수집 방식과 안티봇 대응을 캡슐화하고, +코어 파이프라인(필터·이상치·AI)은 정규화된 NormalizedProduct 만 본다. +새 소스는 SearchAdapter 를 구현하기만 하면 코어 변경 없이 붙는다(트레드밀 격리). +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, Field + + +class NormalizedProduct(BaseModel): + """소스 무관 정규화 상품 스키마. 어댑터의 유일한 출력 계약.""" + + source: str = Field(description="수집 소스 (naver|coupang)") + name: str = Field(description="상품명") + price: int = Field(description="판매가(원, 정수). 파싱 실패분은 어댑터에서 제외") + model: Optional[str] = Field(None, description="모델명(있으면)") + manufacturer: Optional[str] = Field(None, description="제조사(있으면)") + image_url: Optional[str] = Field(None, description="썸네일 URL") + detail_url: Optional[str] = Field(None, description="상품 상세 URL") + shipping_fee: Optional[int] = Field(None, description="배송비(원). 무료=0, 미확인=None") + shipping_type: Optional[str] = Field(None, description="배송 유형: free(명시 무료)|paid(유료)|rocket(로켓배송, 조건부 무료)|rocket_merchant(판매자로켓)|None(미확인). 네이버는 lprice 가 배송비 제외 상품가라 항상 None") + mall_name: Optional[str] = Field(None, description="판매몰/스토어명") + external_id: Optional[str] = Field(None, description="소스 내 상품 식별자") + + +class AdapterHealth(BaseModel): + """어댑터 건강도. 성공률 급락 = 레이아웃 변경/차단 신호 → 알림 훅.""" + + source: str + ok: bool = Field(description="현재 정상 동작 여부") + recent_success_rate: float = Field(0.0, description="최근 요청 성공률(0~1)") + blocked_rate: float = Field(0.0, description="최근 차단(봇탐지) 비율(0~1)") + note: str = "" + + +class AdapterError(Exception): + """어댑터 수집 실패. blocked=True 면 안티봇 차단으로 판단(에스컬레이션/알림 트리거).""" + + def __init__(self, message: str, *, source: str, blocked: bool = False): + super().__init__(message) + self.source = source + self.blocked = blocked + + +class SearchAdapter(ABC): + """검색 소스 어댑터. 소스별 수집/에스컬레이션/안티봇을 내부에 캡슐화한다.""" + + source: str + + @abstractmethod + async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]: + """query 로 검색해 정규화 상품 리스트를 반환. 차단 시 AdapterError(blocked=True).""" + raise NotImplementedError + + async def health(self) -> AdapterHealth: + """기본 건강도. 어댑터가 관측 지표를 축적하면 override.""" + return AdapterHealth(source=self.source, ok=True) diff --git a/lps/services/search/coupang/adapter.py b/lps/services/search/coupang/adapter.py new file mode 100644 index 0000000..9afbe67 --- /dev/null +++ b/lps/services/search/coupang/adapter.py @@ -0,0 +1,46 @@ +"""쿠팡 검색 어댑터. + +쿠팡은 Akamai Bot Manager 의 JS 행동 챌린지를 걸어 curl_cffi 단독으로는 통과 못 한다. +→ Patchright(스텔스 Playwright) + 실제 Chrome 으로 챌린지를 통과한다(BrowserSearchAdapter 공유). +persistent context 로 브라우저를 재사용하므로 챌린지는 (쿠키 만료 전까지) 1회만 풀린다. + +쿠팡 차단은 여러 flavor 다 — Akamai JS 챌린지 / Edge Access Denied / 권한제한 페이지. +detect_block 은 순수 함수로 분리해 단위 테스트한다(browser_base.detect_block 에 쿠팡 마커 주입). +""" + +from urllib.parse import quote + +from services.search.browser_base import BrowserSearchAdapter, detect_block as _detect_block +from services.search.contract import NormalizedProduct +from services.search.coupang.parser import parse_search_html +from services.search.coupang.selectors import SELECTORS + +_SEARCH_URL = "https://www.coupang.com/np/search?q={q}&channel=user&listSize={n}" +# 차단 마커 3계열: Akamai JS 챌린지 / Edge Access Denied(수백 B) / 권한제한 페이지. +_BLOCK_MARKERS = ( + "sec-if-cpt-container", "Powered and protected", "/akam/", + "errors.edgesuite.net", "You don't have permission to access", + "사용권한이 제한된", "쿠팡을 찾아주신 고객님", +) +# 정상 검색결과·'검색결과 없음'은 수십 KB+. 이보다 짧은데 0건이면 미지의 차단으로 간주(회전 트리거). +_MIN_RESULT_HTML = 10000 + + +def detect_block(html: str, product_count: int) -> str | None: + """쿠팡 0건 응답의 차단 여부 판정(순수 함수 — 브라우저 무관, 단위 테스트 가능).""" + return _detect_block(html, product_count, _BLOCK_MARKERS, _MIN_RESULT_HTML) + + +class CoupangAdapter(BrowserSearchAdapter): + source = "coupang" + block_markers = _BLOCK_MARKERS + min_result_html = _MIN_RESULT_HTML + ready_selector = SELECTORS.card + ready_timeout_ms = 20000 + block_resources_default = True # 이미지/미디어/폰트/CSS 차단 → 대역폭↓(Akamai·상품데이터엔 불필요) + + def _search_url(self, query: str, limit: int) -> str: + return _SEARCH_URL.format(q=quote(query), n=limit) + + def _parse(self, html: str) -> list[NormalizedProduct]: + return parse_search_html(html, source=self.source) diff --git a/lps/services/search/coupang/parser.py b/lps/services/search/coupang/parser.py new file mode 100644 index 0000000..30d20f0 --- /dev/null +++ b/lps/services/search/coupang/parser.py @@ -0,0 +1,102 @@ +"""쿠팡 검색결과 HTML → NormalizedProduct[] (순수 함수, selectolax). + +브라우저/네트워크와 무관. 저장된 HTML 로 결정론적 단위 테스트가 가능하다. +""" + +import re + +from selectolax.parser import HTMLParser + +from services.search.contract import NormalizedProduct +from services.search.coupang.selectors import SELECTORS as S + +BASE = "https://www.coupang.com" + +# '원' 바로 앞의 숫자만 가격으로 인식('(1개당 44,400원)'의 앞 '1' 오인 방지). +_WON = re.compile(r"([\d,]+)\s*원") +# 카드 내 명시 배송비(예: '배송비 3,000원'). +_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원") + + +def _sale_price(price_area) -> int | None: + """판매가 추출. 정가(del)·단위가격('~당', 괄호)·할인율(%)은 제외하고 + 본문 판매가 노드(문서 순서상 먼저 오는 '원' 값)를 취한다.""" + if price_area is None: + return None + for node in price_area.css("span, div, strong"): + if node.tag == "del": + continue + text = node.text(strip=True) or "" + if "원" not in text or "당" in text or text.startswith("("): + continue # 단위가격/부가문구 제외 + m = _WON.search(text) + if m: + return int(m.group(1).replace(",", "")) + return None + + +def _shipping(card, name: str | None) -> tuple[int | None, str | None]: + """카드에서 (배송비, 배송유형) 추출. + 유형은 로켓 뱃지(img src)로, 금액은 '무료배송'/'배송비 X원' 텍스트로 판별한다. + 로켓 계열은 조건부 무료(와우/최소금액)라 명시 텍스트 없으면 배송비 None 유지. + 상품명에 '무료배송' 이 들어간 오탐을 막기 위해 이름 텍스트는 제거 후 매칭.""" + badge_type = None + for img in card.css(S.rocket_badge): + src = img.attributes.get("src") or "" + badge_type = "rocket_merchant" if S.rocket_merchant_marker in src else "rocket" + if badge_type == "rocket": + break # 로켓배송 뱃지가 가장 강한 신호 + + text = card.text(separator=" ") or "" + if name: + text = text.replace(name, " ") + + if "무료배송" in text: + return 0, badge_type or "free" + m = _SHIP_FEE.search(text) + if m: + return int(m.group(1).replace(",", "")), badge_type or "paid" + return None, badge_type + + +def parse_search_html(html: str, source: str = "coupang") -> list[NormalizedProduct]: + tree = HTMLParser(html) + products: list[NormalizedProduct] = [] + + for card in tree.css(S.card): + name_el = card.css_first(S.name) + name = name_el.text(strip=True) if name_el else None + + img = card.css_first(S.image) + if not name and img: + name = img.attributes.get("alt") + image_url = img.attributes.get("src") if img else None + + price_area = card.css_first(S.price_area) + price = _sale_price(price_area) + + # 이름/가격이 없으면 유효 상품이 아니므로 스킵(광고 슬롯 등) + if not name or price is None: + continue + + a = card.css_first(S.link) + href = a.attributes.get("href") if a else None + detail_url = (BASE + href) if href and href.startswith("/") else href + + shipping_fee, shipping_type = _shipping(card, name) + + products.append( + NormalizedProduct( + source=source, + name=name, + price=price, + image_url=image_url, + detail_url=detail_url, + mall_name="쿠팡", + external_id=card.attributes.get(S.data_id_attr), + shipping_fee=shipping_fee, + shipping_type=shipping_type, + ) + ) + + return products diff --git a/lps/services/search/coupang/selectors.py b/lps/services/search/coupang/selectors.py new file mode 100644 index 0000000..8552729 --- /dev/null +++ b/lps/services/search/coupang/selectors.py @@ -0,0 +1,25 @@ +"""쿠팡 검색결과 셀렉터(외부화). + +쿠팡 클래스는 webpack 해시 suffix(예: ProductUnit_productUnit__Qd6sv)를 달고 있어 +배포마다 suffix 가 바뀐다. 따라서 접두 부분 매칭([class*=...])으로 견고성을 확보한다. +레이아웃이 바뀌면 이 파일만 고치면 되도록 파서에서 분리한다(new.md: 셀렉터 외부 설정). +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CoupangSelectors: + card: str = "li[class*=ProductUnit_productUnit]" + name: str = "[class*=ProductUnit_productNameV2]" + price_area: str = "[class*=PriceArea_priceArea]" + orig_price: str = "del" # price_area 내부: 정가(취소선) + link: str = "a[href]" + image: str = "figure img" + data_id_attr: str = "data-id" # li 의 vendorItemId + # 배송 뱃지: 로고 이미지 src 로 판별(배송 텍스트는 해시 없는 인라인 스타일 span 이라 텍스트 매칭). + rocket_badge: str = "img[src*=rocket]" + rocket_merchant_marker: str = "rocket_merchant" # src 에 포함 시 판매자로켓, 그 외 rocket* 는 로켓배송 + + +SELECTORS = CoupangSelectors() diff --git a/lps/services/search/esm/__init__.py b/lps/services/search/esm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lps/services/search/esm/adapter.py b/lps/services/search/esm/adapter.py new file mode 100644 index 0000000..0b60e6e --- /dev/null +++ b/lps/services/search/esm/adapter.py @@ -0,0 +1,87 @@ +"""ESM(G마켓·옥션) 검색 어댑터 — 네이버 폴백용(네이버에 해당 몰이 없을 때만 크롤). + +두 사이트는 eBay Korea 백엔드 공유 + '잠시만 기다리십시오…' JS 챌린지(수 초 후 자동 통과)가 +동일해, BrowserSearchAdapter 위에 site 파라미터 하나로 커버한다(카드/셀렉터만 분기). +챌린지는 ready_selector(카드) 가 렌더될 때까지 대기하면 자연히 통과한다. +""" + +import time +from urllib.parse import quote + +from common.logger import LOG +from services.search.browser_base import BrowserSearchAdapter +from services.search.contract import NormalizedProduct +from services.search.card_parser import parse_cards +from services.search.esm.selectors import SITES + +# ESM 차단 마커. '잠시만 기다리십시오' 챌린지는 렌더 완료 후에도(parse=0) 남아 있으면 = 이 IP 가 +# 챌린지를 못 푼 것 → 차단으로 간주해 IP 회전 재시도를 유도한다(정상 시엔 카드가 떠 parse>0 이라 여기 안 옴). +_BLOCK_MARKERS = ("errors.edgesuite.net", "You don't have permission to access", "비정상적인 접근", "잠시만 기다리") + + +# Cloudflare Turnstile('사람인지 확인' 체크박스) 위젯 iframe. +_CF_IFRAME = 'iframe[src*="challenges.cloudflare.com"]' + + +class EsmAdapter(BrowserSearchAdapter): + block_markers = _BLOCK_MARKERS + min_result_html = 8000 # 챌린지 페이지(~21KB)는 크므로 short 폴백은 명백한 에러만 + # G마켓/옥션은 Cloudflare Turnstile. patchright 가 콜드 ~12초에 자동 통과하고, 통과하면 cf_clearance + # 쿠키로 이후 요청은 ~5초(웜). 콜드 자동통과 여유로 25초까지 대기(잡은 폴백 데드라인 15s 가 상한). + ready_timeout_ms = 25000 + # Cloudflare Turnstile 은 리소스 로딩 패턴까지 지문검사 → 챌린지 solving 중 차단하면 봇 판정(이미지만 막아도 실패). + # 하지만 **cf_clearance 확보(웜) 후엔 차단해도 재챌린지 없음**(실측) → 동적 차단: 콜드=허용, 웜=차단. + block_resources_default = True + blocked_resource_types = {"image", "media", "font"} + + def __init__(self, site: str, **kwargs): + if site not in SITES: + raise ValueError(f"unknown ESM site: {site}") + self._site = SITES[site] + self.source = site # base __init__ 의 user_data_dir 기본값에 쓰임 + self.ready_selector = self._site.ready_selector + super().__init__(**kwargs) + + def _search_url(self, query: str, limit: int) -> str: + return self._site.search_url.format(q=quote(query)) + + def _parse(self, html: str) -> list[NormalizedProduct]: + return parse_cards(html, self._site.cards) + + async def _blocking_now(self) -> bool: + """Turnstile 챌린지를 이미 통과(cf_clearance 쿠키 있음)했을 때만 리소스 차단. + 콜드(쿠키 없음)엔 차단하면 Turnstile 지문검사에 걸리므로 허용.""" + if not self._block_resources or self._ctx is None: + return False + try: + cookies = await self._ctx.cookies(self._site.search_url) + return any(c.get("name") == "cf_clearance" for c in cookies) + except Exception: + return False + + async def _wait_ready(self, page): + """Turnstile 대응 렌더 대기: 카드가 뜰 때까지 폴링하되, 매 회 인터랙티브 체크박스면 클릭 시도. + 대부분은 patchright 가 자동 통과(클릭 무해). cf_clearance 웜 상태면 카드가 곧바로 뜬다.""" + deadline = time.monotonic() + self.ready_timeout_ms / 1000 + while time.monotonic() < deadline: + try: + if await page.query_selector(self.ready_selector): + return + except Exception: + pass + await self._try_pass_turnstile(page) + await page.wait_for_timeout(1000) + + async def _try_pass_turnstile(self, page): + """Turnstile 인터랙티브 체크박스(나쁜 IP 에스컬레이션)면 클릭. best-effort — 없거나 실패해도 무시. + 위젯은 challenges.cloudflare.com cross-origin iframe 안에 있다.""" + try: + fl = page.frame_locator(_CF_IFRAME) + for sel in ('input[type="checkbox"]', 'label'): + loc = fl.locator(sel) + if await loc.count(): + await loc.first.click(timeout=1200) + LOG.d(f"[{self.source}] Turnstile 체크박스 클릭 시도") + return + except Exception: + pass diff --git a/lps/services/search/esm/selectors.py b/lps/services/search/esm/selectors.py new file mode 100644 index 0000000..718af80 --- /dev/null +++ b/lps/services/search/esm/selectors.py @@ -0,0 +1,38 @@ +"""ESM(G마켓·옥션, eBay Korea 공유 백엔드) 검색결과 셀렉터/설정. + +두 사이트는 백엔드를 공유하지만 검색결과 HTML 템플릿은 서로 달라, 카드/이름/가격 셀렉터가 +다르다(일부 가격 클래스만 공유). 사이트별 설정을 외부화한다 — 레이아웃 변경 시 여기만. +""" + +from dataclasses import dataclass + +from services.search.card_parser import CardConfig + + +@dataclass(frozen=True) +class EsmSite: + search_url: str # {q} 자리표시자 + ready_selector: str # 챌린지 통과·렌더 완료 신호 + cards: CardConfig + + +GMARKET = EsmSite( + search_url="https://browse.gmarket.co.kr/search?keyword={q}", + ready_selector="div.box__item-container", + cards=CardConfig( + source="gmarket", mall_name="G마켓", base_url="https://browse.gmarket.co.kr/", + card="div.box__item-container", name="[class*=text__item-title]", price="[class*=box__price-seller]", + ), +) + +AUCTION = EsmSite( + search_url="https://browse.auction.co.kr/search?keyword={q}", + ready_selector="div.itemcard", + cards=CardConfig( + source="auction", mall_name="옥션", base_url="https://browse.auction.co.kr/", + card="div.itemcard", name="span.text--title", price="strong.text__price-seller", + link="a.link--itemcard", + ), +) + +SITES = {"gmarket": GMARKET, "auction": AUCTION} diff --git a/lps/services/search/naver/adapter.py b/lps/services/search/naver/adapter.py new file mode 100644 index 0000000..2995c24 --- /dev/null +++ b/lps/services/search/naver/adapter.py @@ -0,0 +1,100 @@ +"""네이버 쇼핑 검색 어댑터. + +공식 오픈 API(https://openapi.naver.com/v1/search/shop.json)라 크롤링/브라우저 불필요. +레퍼런스의 핵심 자산인 **키 로테이션**을 이식: 429/403(쿼터/차단) 시 다음 키로 순환 재시도. +키는 config.local.toml [NaverConfig].keys 에서 로드(여러 개면 로테이션). +""" + +import httpx + +from common.logger import LOG +from config.server_configs import naver_config +from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth +from services.search.rate_limiter import RateLimiter +from services.search.naver.transform import transform_items + +_API = "https://openapi.naver.com/v1/search/shop.json" +_MAX_START = 1000 # 네이버 start 상한 +_MAX_DISPLAY = 100 + + +def load_naver_keys() -> list[tuple[str, str]]: + """(client_id, client_secret) 쌍 목록. TOML [NaverConfig].keys 에서 로드(여러 개면 로테이션).""" + return [(k.id, k.secret) for k in naver_config.keys if k.id and k.secret] + + +class NaverAdapter(SearchAdapter): + source = "naver" + + def __init__(self, keys: list[tuple[str, str]] | None = None, rate_limiter: RateLimiter | None = None, timeout: float = 10.0): + self._keys = keys if keys is not None else load_naver_keys() + self._idx = 0 + self._rl = rate_limiter or RateLimiter(0.1, 0.3) # 공식 API — 짧은 간격 + self._timeout = timeout + self._ok = 0 + self._blocked = 0 + self.last_bytes = 0 # 직전 search 의 응답 바이트(계측용) + + uses_proxy = False # 네이버는 오픈API 직접 호출(프록시 미경유) — DECODO 대역폭 비용 없음 + + def _headers(self) -> dict: + cid, csec = self._keys[self._idx] + return {"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": csec} + + def _rotate(self): + self._idx = (self._idx + 1) % len(self._keys) + + async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]: + if not self._keys: + raise AdapterError("네이버 API 키 없음(config.local.toml [NaverConfig].keys)", source=self.source) + + self.last_bytes = 0 + collected: list[dict] = [] + async with httpx.AsyncClient(timeout=self._timeout) as client: + start = 1 + while len(collected) < limit and start <= _MAX_START: + display = min(_MAX_DISPLAY, limit - len(collected)) + data = await self._request(client, { + "query": query, "display": display, "start": start, + "sort": "sim", "exclude": "used:rental:cbshop", + }) + items = data.get("items", []) + if not items: + break + collected.extend(items) + start += display + if len(items) < display: + break + + products = transform_items(collected, self.source) + self._ok += 1 + LOG.d(f"[naver] query={query!r} → {len(products)}건 (limit {limit})") + return products[:limit] + + async def _request(self, client: httpx.AsyncClient, params: dict) -> dict: + """키 개수만큼 재시도. 429/403 이면 다음 키로 로테이션.""" + last_status = None + for _ in range(max(1, len(self._keys))): + await self._rl.wait() + r = await client.get(_API, params=params, headers=self._headers()) + if r.status_code == 200: + self.last_bytes += len(r.content) + return r.json() + if r.status_code in (429, 403): + self._blocked += 1 + last_status = r.status_code + LOG.w(f"[naver] {r.status_code} → 키 로테이션(idx {self._idx})") + self._rotate() + continue + raise AdapterError(f"네이버 API 오류 {r.status_code}: {r.text[:200]}", source=self.source) + raise AdapterError(f"네이버 API 쿼터/차단(모든 키 소진, last={last_status})", source=self.source, blocked=True) + + async def health(self) -> AdapterHealth: + total = self._ok + self._blocked + rate = (self._ok / total) if total else 0.0 + return AdapterHealth(source=self.source, ok=(self._blocked == 0 or rate > 0.5), + recent_success_rate=rate, blocked_rate=(self._blocked / total) if total else 0.0) + + async def close(self): + """httpx 클라이언트를 search 마다 생성/정리하므로 별도 정리 불필요(수명주기 통일용 no-op).""" + return diff --git a/lps/services/search/naver/transform.py b/lps/services/search/naver/transform.py new file mode 100644 index 0000000..07b4380 --- /dev/null +++ b/lps/services/search/naver/transform.py @@ -0,0 +1,52 @@ +"""네이버 쇼핑 API 응답 items → NormalizedProduct (순수 함수). + +네트워크 무관 — 저장된/모의 JSON 으로 결정론적 테스트 가능. +title 의 강조 태그·HTML 엔티티를 제거하고, 가격비교(catalog) 페이지는 제외한다. +""" + +import html +import re + +from services.search.contract import NormalizedProduct + +_TAG = re.compile(r"<[^>]+>") + + +def _clean(text: str) -> str: + return html.unescape(_TAG.sub("", text or "")).strip() + + +def transform_items(items: list[dict], source: str = "naver") -> list[NormalizedProduct]: + products: list[NormalizedProduct] = [] + for it in items: + link = it.get("link", "") or "" + # 가격비교(catalog, productType=1) 페이지의 lprice 는 '여러 판매자 중 최저가'라 + # 최저가 솔루션에는 오히려 핵심 신호 → 제외하지 않고 그대로 취한다. + # 단, lprice 는 **배송비 제외** 상품가(API 에 배송비 필드 없음) — 카탈로그 화면의 + # '배송비포함 최저가'와 다를 수 있다. 카탈로그 크롤링은 WTM 캡차로 차단됨(2026-07 스파이크) + # → shipping_fee/shipping_type 은 None(미확인)으로 남긴다. + + try: + price = int(it.get("lprice")) # lprice = 최저가 + except (TypeError, ValueError): + continue + if price <= 0: + continue + + name = _clean(it.get("title")) + if not name: + continue + + products.append( + NormalizedProduct( + source=source, + name=name, + price=price, + image_url=it.get("image") or None, + detail_url=link or None, + mall_name=it.get("mallName") or None, + manufacturer=(it.get("maker") or it.get("brand")) or None, + external_id=str(it["productId"]) if it.get("productId") else None, + ) + ) + return products diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py new file mode 100644 index 0000000..977c1c4 --- /dev/null +++ b/lps/services/search/proxy.py @@ -0,0 +1,84 @@ +"""DECODO(구 Smartproxy) residential 프록시 제공자 — 쿠팡(Akamai) 전용. + +Decodo residential 은 **포트 기반 sticky** 모델이다: + gate.decodo.com : 10001..N (각 포트 = 별도 sticky 세션, 대시보드에서 지속시간 지정: 예 10분) + username/password 는 고정. → **IP 회전 = 포트를 바꾸는 것.** + +매 요청 IP 변경은 Akamai 가 '쿠키-IP 불일치'로 재챌린지하므로 금물. +→ 시간창(now // window)으로 포트를 고른다: 창 안에선 같은 포트=같은 IP, 창이 지나면 다음 포트=새 IP. +자격증명/엔드포인트는 config.local.toml [DecodoConfig] 에서 로드(시크릿). +""" + +import time +from urllib.parse import quote + +import httpx + +from common.logger import LOG +from config.server_configs import decodo_config + + +class DecodoProxy: + def __init__(self, cfg=None): + cfg = cfg if cfg is not None else decodo_config + self.host = cfg.host + self.username = cfg.username + self.password = cfg.password + self.port_start = cfg.port_start + self.port_end = cfg.port_end + self.session_minutes = cfg.session_minutes or 10 + self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가 + + @property + def enabled(self) -> bool: + return all([self.host, self.username, self.password, self.port_start, self.port_end]) + + def rotate(self): + """시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지 시 호출.""" + self._rotate_offset += 1 + + def seed_offset(self, k: int): + """워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다.""" + self._rotate_offset = k + + def _port(self) -> int: + """시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP.""" + n = self.port_end - self.port_start + 1 + bucket = int(time.time() // (self.session_minutes * 60)) + return self.port_start + ((bucket + self._rotate_offset) % n) + + @property + def current_port(self): + return self._port() if self.enabled else None + + def playwright_proxy(self) -> dict | None: + """Playwright launch(proxy=...) 용 설정. 비활성 시 None(프록시 미사용).""" + if not self.enabled: + return None + return { + "server": f"http://{self.host}:{self._port()}", + "username": self.username, + "password": self.password, + } + + def _proxy_url(self, port: int) -> str: + return f"http://{quote(self.username)}:{quote(self.password)}@{self.host}:{port}" + + async def healthcheck(self, timeout: float = 6.0) -> tuple[str | None, int | None]: + """시작 프리플라이트: 현재 포트로 egress IP 확인, 실패하면 회전하며 살아있는 포트를 찾는다. + 반환: (egress_ip, port) 성공 / (None, None) 전 포트 실패. residential IP 는 실행 중에도 + 죽으므로 이건 '빠른 실패+가시성'용이고, 실제 회복은 런타임 IP 회전이 담당한다.""" + if not self.enabled: + return None, None + n = self.port_end - self.port_start + 1 + for _ in range(n): + port = self._port() + try: + async with httpx.AsyncClient(proxy=self._proxy_url(port), timeout=timeout) as c: + r = await c.get("https://ip.decodo.com/ip") + if r.status_code == 200: + return r.text.strip(), port + except Exception as ex: + LOG.d(f"[proxy] 포트 {port} 헬스체크 실패: {type(ex).__name__} → 회전") + self.rotate() + return None, None diff --git a/lps/services/search/rate_limiter.py b/lps/services/search/rate_limiter.py new file mode 100644 index 0000000..66a5903 --- /dev/null +++ b/lps/services/search/rate_limiter.py @@ -0,0 +1,26 @@ +"""정중한 요청을 위한 랜덤 딜레이 레이트리미터. + +기계적 등간격 요청은 탐지 신호(new.md). 요청 사이에 2~8s 랜덤 간격을 둔다. +소스(도메인)별로 인스턴스를 두고 마지막 요청 시각을 기준으로 최소 간격을 보장한다. +""" + +import asyncio +import random +import time + + +class RateLimiter: + def __init__(self, min_delay: float = 2.0, max_delay: float = 8.0): + self._min = min_delay + self._max = max_delay + self._last = 0.0 + self._lock = asyncio.Lock() + + async def wait(self) -> None: + """직전 요청으로부터 랜덤 간격이 지나도록 대기(동시 호출 직렬화).""" + async with self._lock: + target = random.uniform(self._min, self._max) + elapsed = time.monotonic() - self._last + if elapsed < target: + await asyncio.sleep(target - elapsed) + self._last = time.monotonic() diff --git a/lps/services/search/st11/__init__.py b/lps/services/search/st11/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lps/services/search/st11/adapter.py b/lps/services/search/st11/adapter.py new file mode 100644 index 0000000..f31ccfe --- /dev/null +++ b/lps/services/search/st11/adapter.py @@ -0,0 +1,31 @@ +"""11번가 검색 어댑터 — 네이버 폴백용(네이버에 11번가가 없을 때만 크롤). + +공식 오픈API 는 판매자 계정 필요라 불가 → PC 웹(search.11st.co.kr) 크롤. +챌린지 없이 렌더되므로 ready_selector(카드) 대기만으로 충분하다. +""" + +from urllib.parse import quote + +from services.search.browser_base import BrowserSearchAdapter +from services.search.contract import NormalizedProduct +from services.search.card_parser import parse_cards +from services.search.st11.selectors import SEARCH_URL, READY_SELECTOR, CARDS + +_BLOCK_MARKERS = ("captcha", "robot", "비정상적인 접근", "errors.edgesuite.net") + + +class ElevenStAdapter(BrowserSearchAdapter): + source = "st11" + block_markers = _BLOCK_MARKERS + min_result_html = 20000 # PC 정상 결과는 수백 KB — 차단/미렌더 페이지는 작다(→회전/재시도) + ready_selector = READY_SELECTOR + ready_timeout_ms = 15000 + block_resources_default = True # 라우팅 on — 이미지/미디어/폰트만(CSS/JS 유지해야 렌더됨) + blocked_resource_types = {"image", "media", "font"} + scroll_steps = 3 # 결과가 지연 로딩 → 스크롤로 트리거 + + def _search_url(self, query: str, limit: int) -> str: + return SEARCH_URL.format(q=quote(query)) + + def _parse(self, html: str) -> list[NormalizedProduct]: + return parse_cards(html, CARDS) diff --git a/lps/services/search/st11/selectors.py b/lps/services/search/st11/selectors.py new file mode 100644 index 0000000..ef92f70 --- /dev/null +++ b/lps/services/search/st11/selectors.py @@ -0,0 +1,20 @@ +"""11번가 PC 검색결과 셀렉터/설정. + +11번가는 판매자 계정이 없어 공식 오픈API 불가 → PC 웹 크롤(모바일 m.11st 는 'robot' 차단). +PC total-search 는 챌린지 없이 렌더된다. +""" + +from services.search.card_parser import CardConfig + +SEARCH_URL = "https://search.11st.co.kr/pc/total-search?kwd={q}" +READY_SELECTOR = "[class*=c-card-item]" + +CARDS = CardConfig( + source="st11", mall_name="11번가", base_url="https://search.11st.co.kr/", + # 카드 토큰 '.c-card-item' (렌더마다 '--list' 접미사 유무가 달라 접미사 매칭은 취약). + # 토큰 매칭이라 서브요소(c-card-item__name 등)는 안 잡힌다. + card=".c-card-item", + name="[class*=c-card-item__name]", + price="[class*=c-card-item__price]", + link="a[href]", +) diff --git a/lps/services/search/util.py b/lps/services/search/util.py new file mode 100644 index 0000000..2d7889f --- /dev/null +++ b/lps/services/search/util.py @@ -0,0 +1,57 @@ +"""검색 어댑터 공용 순수 유틸(부작용 없음). 레퍼런스의 parse_price 를 정리 이식.""" + +import re + +_NUM = re.compile(r"[\d,]+") +_WON = re.compile(r"([\d,]+)\s*원") # '원' 바로 앞 숫자(단위가격/퍼센트 오인 방지) +_NUM3 = re.compile(r"([\d,]{3,})") # 3자리+ 숫자 토큰(가격 후보) +_SHIP_FEE = re.compile(r"배송비\s*([\d,]+)\s*원") +_LABELS = ("상품명", "브랜드명", "판매가", "할인가") # 오픈마켓 카드의 a11y 라벨 프리픽스 + + +def parse_price(value) -> int | None: + """'44,900원', 44900, '44.900' 등 다형 입력 → int. 실패 시 None.""" + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + m = _NUM.search(str(value)) + if not m: + return None + digits = m.group(0).replace(",", "") + return int(digits) if digits.isdigit() else None + + +def extract_price(text: str) -> int | None: + """가격 노드 텍스트 → int. '원' 앵커 우선, 없으면 첫 3자리+ 숫자. + ('1%할인가44,540원' → 44540, '44,540' → 44540). 실패 시 None.""" + if not text: + return None + m = _WON.search(text) + if m: + return int(m.group(1).replace(",", "")) + m = _NUM3.search(text) + return int(m.group(1).replace(",", "")) if m else None + + +def clean_name(text: str) -> str: + """오픈마켓 카드 이름에서 a11y 라벨('상품명'/'브랜드명' 등) 프리픽스를 제거.""" + s = (text or "").strip() + for lab in _LABELS: + s = s.replace(lab, " ") + return re.sub(r"\s+", " ", s).strip() + + +def shipping_from_text(text: str, name: str | None = None) -> tuple[int | None, str | None]: + """카드 텍스트에서 (배송비, 배송유형) 추출 — 오픈마켓 공용(로켓 없음). + '무료배송'→(0,'free'), '배송비 X원'→(X,'paid'), 그 외 (None, None). + 상품명에 '무료배송'이 들어간 오탐 방지를 위해 이름 제거 후 매칭.""" + t = text or "" + if name: + t = t.replace(name, " ") + if "무료배송" in t: + return 0, "free" + m = _SHIP_FEE.search(t) + if m: + return int(m.group(1).replace(",", "")), "paid" + return None, None diff --git a/lps/tests/fixtures/auction_search.html b/lps/tests/fixtures/auction_search.html new file mode 100644 index 0000000..6877fe3 --- /dev/null +++ b/lps/tests/fixtures/auction_search.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/lps/tests/fixtures/coupang_search.html b/lps/tests/fixtures/coupang_search.html new file mode 100644 index 0000000..e4fb402 --- /dev/null +++ b/lps/tests/fixtures/coupang_search.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/lps/tests/fixtures/gmarket_search.html b/lps/tests/fixtures/gmarket_search.html new file mode 100644 index 0000000..fbb6538 --- /dev/null +++ b/lps/tests/fixtures/gmarket_search.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/lps/tests/fixtures/st11_search.html b/lps/tests/fixtures/st11_search.html new file mode 100644 index 0000000..d97e47a --- /dev/null +++ b/lps/tests/fixtures/st11_search.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/lps/tests/test_bot_detection.py b/lps/tests/test_bot_detection.py new file mode 100644 index 0000000..78a0d43 --- /dev/null +++ b/lps/tests/test_bot_detection.py @@ -0,0 +1,34 @@ +"""봇 감지 이력 기록 CRUD 테스트 (실 lps_db).""" + +import pytest_asyncio +from sqlalchemy import text + +from crud.bot_detection import BotDetectionLog + + +@pytest_asyncio.fixture +async def bd(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE bot_detection")) + return BotDetectionLog() + + +async def test_record_persists_event(bd, db_engine): + await bd.record({ + "source": "coupang", "query": "맥심 커피", "ip_request_no": 7, + "proxy_port": 10003, "elapsed_sec": 42, "marker": "/akam/", + "headless": False, "html_len": 2604, + }) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT source, query, ip_request_no, proxy_port, marker FROM bot_detection" + ))).first() + assert row.source == "coupang" and row.query == "맥심 커피" + assert row.ip_request_no == 7 and row.proxy_port == 10003 and row.marker == "/akam/" + + +async def test_record_tolerates_missing_fields(bd, db_engine): + await bd.record({"source": "coupang"}) # 나머지는 None 허용 + async with db_engine.begin() as conn: + cnt = (await conn.execute(text("SELECT count(*) FROM bot_detection"))).scalar() + assert cnt == 1 diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py new file mode 100644 index 0000000..f370c96 --- /dev/null +++ b/lps/tests/test_browser_base.py @@ -0,0 +1,146 @@ +"""브라우저 어댑터 search() 오케스트레이션 테스트. + +가장 취약·복잡한 경로(차단 감지→IP 회전 재시도, 프록시 전송오류→회전, 소진→실패)를 +mock 페이지로 **결정론적**으로 검증한다(실제 브라우저/네트워크 없이). 파서 자체는 +test_coupang_parser / test_openmarket_parser 가 저장 HTML 로 커버. + ++ 라이브 스모크: 실제 사이트에 붙어 각 어댑터가 결과를 파싱하는지(셀렉터·안티봇 드리프트 감지). + 느리고 IP 의존적이라 기본 skip — LPS_LIVE=1 로 명시 실행(수동/야간용). +""" + +import os + +import pytest + +from services.search.browser_base import BrowserSearchAdapter +from services.search.contract import NormalizedProduct, AdapterError +from services.search.rate_limiter import RateLimiter + + +# ── mock 하니스: page.goto/content 를 스크립트로 제어 ────────────────── +class _MockPage: + """steps: 시도별 결과. Exception→goto 가 raise, str→content 가 그 HTML 반환.""" + def __init__(self, steps): + self._steps, self._i, self._cur = steps, 0, None + + async def goto(self, url, **kw): + self._cur = self._steps[self._i] + self._i += 1 + if isinstance(self._cur, BaseException): + raise self._cur + + async def content(self): + return self._cur + + async def wait_for_selector(self, *a, **k): pass + async def query_selector(self, *a, **k): return None + async def wait_for_timeout(self, *a, **k): pass + async def evaluate(self, *a, **k): return None + + +class _MockCtx: + def __init__(self, page): self.pages = [page] + async def new_page(self): return self.pages[0] + async def cookies(self, *a): return [] + async def new_cdp_session(self, *a): raise RuntimeError("no cdp") # → DOM 바이트 폴백 + async def close(self): pass + + +class _MockProxy: + enabled = True + session_minutes = 10 + def __init__(self): self.rotations = 0 + def rotate(self): self.rotations += 1 + def playwright_proxy(self): return None + @property + def current_port(self): return 10001 + + +class _MockAdapter(BrowserSearchAdapter): + source = "mock" + block_markers = ("BOTBLOCK",) + min_result_html = 50 # 이보다 짧은 0건 응답 = short_html 차단 + + def __init__(self, page, **kw): + self._page = page + super().__init__(rate_limiter=RateLimiter(0, 0), **kw) + + async def _ensure_browser(self): + if self._ctx is None or self._force_recycle: # 회전(force_recycle) 시 재기동 흉내 + self._ctx = _MockCtx(self._page) + self._force_recycle = False + self._ip_requests = 0 + + async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기 + async def _wait_ready(self, page): pass + async def _blocking_now(self): return False + def _search_url(self, q, l): return "http://mock" + def _parse(self, html): + return [NormalizedProduct(source="mock", name="p", price=100)] if "PRODUCT" in html else [] + + +def _ad(steps): + return _MockAdapter(_MockPage(steps), proxy=_MockProxy()) + + +async def test_search_returns_products_no_rotation(): + ad = _ad(["ok"]) + r = await ad.search("q", limit=5) + assert len(r) == 1 and ad._proxy.rotations == 0 + + +async def test_block_rotates_ip_then_recovers(): + # 시도1: 짧은 HTML(차단) → IP 회전 / 시도2: 상품 + ad = _ad(["x", "ok"]) + r = await ad.search("q") + assert len(r) == 1 and ad._proxy.rotations == 1 + + +async def test_proxy_transport_error_rotates_then_recovers(): + ad = _ad([RuntimeError("Page.goto: net::ERR_TUNNEL_CONNECTION_FAILED"), "ok"]) + r = await ad.search("q") + assert len(r) == 1 and ad._proxy.rotations == 1 + + +async def test_non_proxy_goto_error_raises_no_rotation(): + ad = _ad([RuntimeError("net::ERR_NAME_NOT_RESOLVED")]) # DNS — 프록시 문제 아님 + with pytest.raises(AdapterError): + await ad.search("q") + assert ad._proxy.rotations == 0 + + +async def test_persistent_block_exhausts_and_raises_blocked(): + # 차단 2회 연속(max_block_retries=1) → 회전 1회 후 소진 → blocked=True 로 실패 + ad = _ad(["x", "x"]) + with pytest.raises(AdapterError) as ei: + await ad.search("q") + assert ei.value.blocked is True and ad._proxy.rotations == 1 + + +# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ────── +def _live_adapters(): + from services.search.proxy import DecodoProxy + from services.search.coupang.adapter import CoupangAdapter + from services.search.esm.adapter import EsmAdapter + from services.search.st11.adapter import ElevenStAdapter + from services.search.naver.adapter import NaverAdapter + p = DecodoProxy() + return [ + ("naver", NaverAdapter()), + ("coupang", CoupangAdapter(headless=True, proxy=p)), + ("gmarket", EsmAdapter("gmarket", headless=True, proxy=p)), + ("auction", EsmAdapter("auction", headless=True, proxy=p)), + ("st11", ElevenStAdapter(headless=True, proxy=p)), + ] + + +@pytest.mark.skipif(not os.environ.get("LPS_LIVE"), reason="라이브 스모크 — LPS_LIVE=1 로 실행") +@pytest.mark.parametrize("name", ["naver", "coupang", "gmarket", "auction", "st11"]) +async def test_live_smoke(name): + ad = dict(_live_adapters())[name] + try: + ps = await ad.search("생수", limit=5) + assert len(ps) > 0, f"{name}: 0건 — 셀렉터/안티봇 드리프트 의심" + assert all(p.price > 0 and p.name for p in ps) + finally: + await ad.close() diff --git a/lps/tests/test_coupang_parser.py b/lps/tests/test_coupang_parser.py new file mode 100644 index 0000000..2806a3a --- /dev/null +++ b/lps/tests/test_coupang_parser.py @@ -0,0 +1,94 @@ +# 쿠팡 파서 결정론적 단위 테스트(네트워크/브라우저 불필요). +# fixture 는 실제 렌더된 검색결과에서 카드 4개를 추출한 것(단위가격 '1개당' 케이스 포함). +from pathlib import Path + +from services.search.coupang.parser import parse_search_html +from services.search.coupang.adapter import detect_block + +FIXTURE = Path(__file__).parent / "fixtures" / "coupang_search.html" + + +def test_parse_extracts_valid_products(): + items = parse_search_html(FIXTURE.read_text()) + + assert len(items) >= 3 + for p in items: + assert p.source == "coupang" + assert p.name and len(p.name) > 2 + # 단위가격('1개당 44,400원')의 '1' 을 가격으로 오인하던 회귀 방지 + assert p.price >= 100, f"이상 저가: {p.price} ({p.name})" + assert p.detail_url and p.detail_url.startswith("https://www.coupang.com/vp/products/") + assert p.external_id # vendorItemId(data-id) + + +def test_parse_price_is_sale_not_unit_price(): + # fixture 첫 카드의 판매가는 44,400원(단위가격도 '1개당 44,400원'이라 값은 같지만 + # 파서가 정가(del)나 '%'가 아닌 판매가를 정확히 집는지 확인) + items = parse_search_html(FIXTURE.read_text()) + assert items[0].price == 44400 + + +def test_parse_shipping_from_fixture(): + # fixture 4카드: [0,1]=로켓뱃지+무료배송, [2]=무료배송 텍스트만, [3]=판매자로켓 뱃지만 + items = parse_search_html(FIXTURE.read_text()) + assert (items[0].shipping_fee, items[0].shipping_type) == (0, "rocket") + assert (items[1].shipping_fee, items[1].shipping_type) == (0, "rocket") + assert (items[2].shipping_fee, items[2].shipping_type) == (0, "free") + # 판매자로켓은 조건부 무료 → 명시 텍스트 없으면 배송비 미확인(None) + assert (items[3].shipping_fee, items[3].shipping_type) == (None, "rocket_merchant") + + +def _card(name: str, price: str, extra: str = "") -> str: + return f"""
  • +
    {name}
    +
    {name}
    +
    {price}
    {extra}
  • """ + + +def test_parse_shipping_paid_fee(): + # 명시 배송비('배송비 3,000원') → paid + 금액 + html = f"
      {_card('AAA 건전지 20개입', '5,880원', '배송비 3,000원')}
    " + p = parse_search_html(html)[0] + assert (p.shipping_fee, p.shipping_type) == (3000, "paid") + + +def test_parse_shipping_name_free_text_no_false_positive(): + # 상품명에 '무료배송' 이 들어가도 배송 무료로 오인하지 않는다 + html = f"
      {_card('무료배송 텀블러 굿즈', '12,000원')}
    " + p = parse_search_html(html)[0] + assert (p.shipping_fee, p.shipping_type) == (None, None) + + +# --- 차단 감지(detect_block): 여러 flavor 를 모두 blocked 로 잡아야 IP 회전이 걸린다 --- + +def test_detect_block_none_when_products_exist(): + # 상품이 파싱됐으면 HTML 내용과 무관하게 차단 아님 + assert detect_block("Access Denied", product_count=3) is None + + +def test_detect_block_akamai_challenge(): + assert detect_block("
    " + "x" * 20000, 0) == "sec-if-cpt-container" + + +def test_detect_block_edge_access_denied(): + # 실제로 잡힌 303B edge deny + html = ('Access Denied

    Access Denied

    ' + "You don't have permission to access ... errors.edgesuite.net") + assert detect_block(html, 0) in ("errors.edgesuite.net", "You don't have permission to access") + + +def test_detect_block_permission_restricted_page(): + html = "쿠팡을 찾아주신 고객님, 입력하신 페이지주소는 사용권한이 제한된 페이지입니다." + assert detect_block(html, 0) in ("사용권한이 제한된", "쿠팡을 찾아주신 고객님") + + +def test_detect_block_short_html_fallback(): + # 마커 없어도 비정상적으로 짧은 0건 응답은 미지의 차단으로 폴백 + marker = detect_block("oops", 0) + assert marker is not None and marker.startswith("short_html(") + + +def test_detect_block_genuine_empty_large_page_not_blocked(): + # 정상 '검색결과 없음'(전체 chrome 포함, 큰 HTML)은 차단 아님 → not_found 로 흘러야 함 + big = "검색결과가 없습니다" + "x" * 20000 + "" + assert detect_block(big, 0) is None diff --git a/lps/tests/test_health.py b/lps/tests/test_health.py new file mode 100644 index 0000000..2446098 --- /dev/null +++ b/lps/tests/test_health.py @@ -0,0 +1,9 @@ +# 프레임워크 골격 스모크 테스트. 도메인 로직이 없어도 앱이 부팅되고 healthz 가 응답하는지 확인한다. +# (DB 없이 통과 — 엔진은 lazy 라 실제 커넥션을 맺지 않는다.) + + +async def test_healthz_ok(client): + res = await client.get("/healthz") + assert res.status_code == 200 + # 기동 시각 문자열(예: "2026-07-08 04:26:58")을 그대로 반환한다. + assert isinstance(res.json(), str) diff --git a/lps/tests/test_job_queue.py b/lps/tests/test_job_queue.py new file mode 100644 index 0000000..488bc22 --- /dev/null +++ b/lps/tests/test_job_queue.py @@ -0,0 +1,93 @@ +"""작업 큐 엔진 테스트 — 원자적 claim(이중할당 불가)·lease 회수·재시도/dead-letter·소유권 가드. +실제 lps_db 에 붙어 검증한다(db_engine 이 스키마 보장).""" + +import asyncio + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import JobStatus, JobType +from crud.job_crud import JobQueue, compute_backoff + + +@pytest_asyncio.fixture +async def q(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE job")) + return JobQueue() + + +async def test_enqueue_claim_complete(q): + jid = await q.enqueue(JobType.SEARCH.value, {"query": "커피"}, dedupe_key="search-커피") + assert jid + job = await q.claim("w1") + assert job and job["job_id"] == jid + assert job["payload"]["query"] == "커피" and job["attempts"] == 1 + assert await q.complete(jid, "w1", {"count": 3}) is True + counts = await q.counts() + assert counts["DONE"] == 1 and counts["PENDING"] == 0 + + +async def test_dedupe_blocks_active_duplicate(q): + a = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k") + b = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k") + assert a and b is None # 활성 중복 차단 + # 완료로 빠지면 같은 키 재적재 가능 + job = await q.claim("w1") + await q.complete(job["job_id"], "w1") + c = await q.enqueue(JobType.SEARCH.value, {"q": 1}, dedupe_key="k") + assert c + + +async def test_atomic_claim_no_double_assignment(q): + N = 12 + for i in range(N): + await q.enqueue(JobType.SEARCH.value, {"i": i}) + # 8개 워커가 동시에 claim → 서로 다른 잡만, 이중 할당 0 + results = await asyncio.gather(*[q.claim(f"w{i}") for i in range(8)]) + claimed = [r["job_id"] for r in results if r] + assert len(claimed) == 8 + assert len(set(claimed)) == 8 + + +async def test_priority_and_order(q): + await q.enqueue(JobType.SEARCH.value, {"n": "low"}, priority=100) + await q.enqueue(JobType.SEARCH.value, {"n": "high"}, priority=1) + job = await q.claim("w1") + assert job["payload"]["n"] == "high" # priority 낮은 값 우선 + + +async def test_lease_reclaim_by_reaper(q): + jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"}) + job = await q.claim("w1", lease_sec=1) + assert job["job_id"] == jid and job["attempts"] == 1 + assert await q.reap() == [] # 아직 lease 유효 → 회수 없음 + await asyncio.sleep(1.3) # lease 만료 + assert jid in await q.reap() # 회수됨(워커 사망 시나리오) + job2 = await q.claim("w2") # 다시 claim 가능, attempts 누적 + assert job2["job_id"] == jid and job2["attempts"] == 2 + + +async def test_retry_then_dead_letter(q): + jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"}, max_attempts=2) + await q.claim("w1") + assert await q.fail(jid, "w1", "boom", backoff_sec=0) == JobStatus.PENDING.value # 1/2 → 재큐 + job2 = await q.claim("w1") + assert job2["attempts"] == 2 + assert await q.fail(jid, "w1", "boom2", backoff_sec=0) == JobStatus.DEAD.value # 2/2 → dead-letter + assert (await q.counts())["DEAD"] == 1 + + +async def test_transitions_require_ownership(q): + jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"}) + await q.claim("w1") + assert await q.complete(jid, "intruder") is False # 소유 아님 → 거부(CAS 가드) + assert await q.fail(jid, "intruder", "no") is None + assert await q.complete(jid, "w1") is True + + +def test_backoff_is_exponential_capped(): + assert compute_backoff(1, base=5) == 5 + assert compute_backoff(2, base=5) == 10 + assert compute_backoff(3, base=5) == 20 + assert compute_backoff(100, base=5, cap=600) == 600 diff --git a/lps/tests/test_lps_api.py b/lps/tests/test_lps_api.py new file mode 100644 index 0000000..3e659a3 --- /dev/null +++ b/lps/tests/test_lps_api.py @@ -0,0 +1,94 @@ +"""LPS API 라우터 테스트 — 검색 적재/중복/상태조회/큐 통계 (ASGI 클라이언트 + 실 lps_db).""" + +import pytest_asyncio +from sqlalchemy import text + + +@pytest_asyncio.fixture +async def clean_jobs(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE job")) + + +async def test_search_enqueues_jobs(client, clean_jobs): + body = {"data": [ + {"product_code": "P1", "product_name": "커피", "job_type": "new_product"}, + {"product_code": "P2", "product_name": "무선마우스", "specification": "M170"}, + ]} + r = await client.post("/v1/lps/search", json=body) + assert r.status_code == 200 + j = r.json() + assert j["accepted"] == 2 + assert {i["product_code"] for i in j["items"]} == {"P1", "P2"} + assert all(i.get("job_id") for i in j["items"]) + + +async def test_search_dedupes_active_product(client, clean_jobs): + body = {"data": [{"product_code": "P1", "product_name": "커피", "job_type": "new_product"}]} + await client.post("/v1/lps/search", json=body) + r2 = await client.post("/v1/lps/search", json=body) # 같은 product_code 재요청 + item = r2.json()["items"][0] + assert item["duplicated"] is True + assert "job_id" not in item # None → RemoveNoneResponse 로 제거됨 + assert r2.json()["accepted"] == 0 + + +async def test_job_status_flow(client, clean_jobs): + jid = (await client.post("/v1/lps/search", json={"data": [{"product_code": "P9", "product_name": "커피"}]})).json()["items"][0]["job_id"] + r = await client.get(f"/v1/lps/jobs/{jid}") + body = r.json() + assert body["status"] == "PENDING" and body["attempts"] == 0 + assert body["result"]["success"] is True + + +async def test_job_status_not_found(client, clean_jobs): + # 존재하지 않는(유효 UUID) 잡 + r = await client.get("/v1/lps/jobs/00000000-0000-0000-0000-000000000000") + assert r.json()["result"]["success"] is False + assert r.json()["result"]["desc"] == "LPS_JOB_NOT_FOUND" + # 잘못된 형식의 id 도 not-found 처리 + r2 = await client.get("/v1/lps/jobs/not-a-uuid") + assert r2.json()["result"]["desc"] == "LPS_JOB_NOT_FOUND" + + +async def test_price_history_endpoint(client, db_engine): + from crud.price_history import PriceHistory + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE price_history")) + ph = PriceHistory() + await ph.record({"product_code": "GRAPH1", "outcome": "found", "final_lowest": 2500, + "final_source": "naver", "naver_lowest": 2500, "coupang_lowest": 2700, "matched_count": 2}) + + r = await client.get("/v1/lps/products/GRAPH1/history") + assert r.status_code == 200 + body = r.json() + assert body["product_code"] == "GRAPH1" + assert len(body["points"]) == 1 + pt = body["points"][0] + assert pt["final"] == 2500 and pt["naver"] == 2500 and pt["coupang"] == 2700 and pt["final_source"] == "naver" + assert "triggered_at" in pt + + +async def test_queue_stats(client, clean_jobs): + await client.post("/v1/lps/search", json={"data": [ + {"product_code": "A", "product_name": "x"}, + {"product_code": "B", "product_name": "y"}, + ]}) + r = await client.get("/v1/lps/queue/stats") + counts = r.json()["counts"] + assert counts["PENDING"] == 2 and counts["DONE"] == 0 and counts["DEAD"] == 0 + + +async def test_readyz(client): + r = await client.get("/readyz") # DB 도달 → ready + assert r.status_code == 200 and r.json()["ready"] is True + + +async def test_ops_snapshot(client, clean_jobs): + await client.post("/v1/lps/search", json={"data": [{"product_code": "A", "product_name": "x"}]}) + r = await client.get("/v1/lps/ops") + assert r.status_code == 200 + j = r.json() + for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec", "blocks_1h"): + assert k in j and isinstance(j[k], int) + assert j["pending"] == 1 diff --git a/lps/tests/test_naver_transform.py b/lps/tests/test_naver_transform.py new file mode 100644 index 0000000..5d49322 --- /dev/null +++ b/lps/tests/test_naver_transform.py @@ -0,0 +1,19 @@ +"""네이버 응답 변환 테스트 (순수, 네트워크 불필요).""" + +from services.search.naver.transform import transform_items + + +def test_transform_strips_tags_and_keeps_catalog(): + items = [ + {"title": "로지텍 무선 마우스 & 키보드", "link": "https://search.shopping.naver.com/catalog/1", + "image": "img1", "lprice": "13500", "mallName": "네이버", "maker": "로지텍", "productId": "1"}, + {"title": "0원상품", "link": "https://x", "lprice": "0"}, # 가격 0 → 제외 + {"title": "가격없음", "link": "https://y", "lprice": "abc"}, # 파싱 실패 → 제외 + ] + out = transform_items(items) + assert len(out) == 1 + p = out[0] + assert p.name == "로지텍 무선 마우스 & 키보드" # 제거 + 엔티티 복원 + assert p.price == 13500 and p.source == "naver" + assert p.mall_name == "네이버" and p.manufacturer == "로지텍" and p.external_id == "1" + assert "catalog/1" in p.detail_url # 가격비교(catalog) 최저가 유지 diff --git a/lps/tests/test_negative_cache.py b/lps/tests/test_negative_cache.py new file mode 100644 index 0000000..7a9d6e6 --- /dev/null +++ b/lps/tests/test_negative_cache.py @@ -0,0 +1,36 @@ +"""네거티브 캐시 CRUD 테스트 (실 lps_db).""" + +import pytest_asyncio +from sqlalchemy import text + +from crud.negative_cache import NegativeCache + + +@pytest_asyncio.fixture +async def nc(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE search_negative")) + return NegativeCache() + + +async def test_put_then_is_negative(nc): + assert await nc.is_negative("P1") is False + await nc.put("P1", ttl_sec=3600) + assert await nc.is_negative("P1") is True + + +async def test_expired_is_not_negative(nc): + await nc.put("P2", ttl_sec=-1) # 이미 만료 + assert await nc.is_negative("P2") is False + + +async def test_upsert_refreshes_ttl(nc): + await nc.put("P3", ttl_sec=-1) # 만료 상태 + assert await nc.is_negative("P3") is False + await nc.put("P3", ttl_sec=3600) # 갱신 → 유효 + assert await nc.is_negative("P3") is True + + +async def test_empty_key_is_noop(nc): + await nc.put("", ttl_sec=3600) + assert await nc.is_negative("") is False diff --git a/lps/tests/test_openmarket_parser.py b/lps/tests/test_openmarket_parser.py new file mode 100644 index 0000000..9c4460a --- /dev/null +++ b/lps/tests/test_openmarket_parser.py @@ -0,0 +1,103 @@ +# 오픈마켓(G마켓·옥션·11번가) 크롤 파서 결정론적 단위 테스트(네트워크/브라우저 불필요). +# fixture 는 실제 렌더된 검색결과에서 카드 3개씩 추출한 것. +from pathlib import Path + +import pytest + +import time + +from services.search.card_parser import parse_cards +from services.search.browser_base import is_proxy_error, BrowserSearchAdapter +from services.search.esm.selectors import GMARKET, AUCTION +from services.search.st11.selectors import CARDS as ST11 + +FIX = Path(__file__).parent / "fixtures" + +CASES = [ + ("gmarket_search.html", GMARKET.cards, "gmarket", "G마켓"), + ("auction_search.html", AUCTION.cards, "auction", "옥션"), + ("st11_search.html", ST11, "st11", "11번가"), +] + + +@pytest.mark.parametrize("fixture,cfg,source,mall", CASES) +def test_parse_extracts_valid_products(fixture, cfg, source, mall): + items = parse_cards((FIX / fixture).read_text(), cfg) + assert len(items) >= 2, f"{source}: 카드 파싱 실패" + for p in items: + assert p.source == source + assert p.mall_name == mall + assert p.name and len(p.name) > 2 + assert "상품명" not in p.name and "브랜드명" not in p.name # a11y 라벨 제거 확인 + assert p.price >= 100, f"이상 저가: {p.price} ({p.name})" + + +@pytest.mark.parametrize("fixture,cfg,source,mall", CASES) +def test_shipping_type_valid(fixture, cfg, source, mall): + items = parse_cards((FIX / fixture).read_text(), cfg) + for p in items: + assert p.shipping_type in (None, "free", "paid") + if p.shipping_type == "paid": + assert p.shipping_fee and p.shipping_fee > 0 + if p.shipping_type == "free": + assert p.shipping_fee == 0 + + +@pytest.mark.parametrize("msg", [ + "Page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://...", + "net::ERR_HTTP_RESPONSE_CODE_FAILURE at https://www.coupang.com/...", + "HTTP ERROR 407 Proxy Authentication Required", + "net::ERR_PROXY_CONNECTION_FAILED", +]) +def test_is_proxy_error_true(msg): + # 프록시 전송 실패(포트/IP 사망·407) → IP 회전 대상 + assert is_proxy_error(msg) is True + + +@pytest.mark.parametrize("msg", [ + "쿠팡 결과 없음/차단 (blocked=True)", + "net::ERR_NAME_NOT_RESOLVED", # DNS — 프록시 문제 아님 + "Timeout 40000ms exceeded", # 단순 타임아웃(사이트 지연) + "", +]) +def test_is_proxy_error_false(msg): + assert is_proxy_error(msg) is False + + +class _IdleAdapter(BrowserSearchAdapter): + source = "test" + def _search_url(self, q, l): return "" + def _parse(self, h): return [] + + +class _FakeCtx: + def __init__(self): self.closed = False + async def close(self): self.closed = True + + +async def test_close_if_idle_keeps_recent_closes_idle(): + ad = _IdleAdapter() + ad._ctx = _FakeCtx() + ad._last_used = time.monotonic() # 방금 사용 + await ad.close_if_idle(60) + assert ad._ctx is not None # 최근 사용 → 유지 + + ctx = ad._ctx + ad._last_used = time.monotonic() - 100 # 100s 전(유휴) + await ad.close_if_idle(60) + assert ad._ctx is None and ctx.closed # 유휴 초과 → 브라우저 정리 + + +async def test_close_if_idle_skips_when_no_ctx(): + ad = _IdleAdapter() # 브라우저 미기동 + await ad.close_if_idle(0) # 예외 없이 no-op + assert ad._ctx is None + + +def test_dedup_by_link(): + # 동일 링크 카드가 반복돼도 1건으로 축약 + card = ('
    상품명 물병' + '
    판매가5,000원
    ') + html = f"
      {card}{card}{card}
    " + items = parse_cards(html, GMARKET.cards) + assert len(items) == 1 and items[0].price == 5000 diff --git a/lps/tests/test_pipeline.py b/lps/tests/test_pipeline.py new file mode 100644 index 0000000..4db98e3 --- /dev/null +++ b/lps/tests/test_pipeline.py @@ -0,0 +1,88 @@ +"""코어 파이프라인 테스트 — 필터·IQR 이상치·top-N 최저가 (결정론적, 네트워크 불필요).""" + +from pathlib import Path + +from services.search.contract import NormalizedProduct +from services.search.coupang.parser import parse_search_html +from services.pipeline.filters import filter_by_price_band, filter_out_malls +from services.pipeline.outliers import remove_price_outliers +from services.pipeline.core import run_price_pipeline, summarize_by_mall + +FIXTURE = Path(__file__).parent / "fixtures" / "coupang_search.html" + + +def _p(price, name="p", mall="쿠팡"): + return NormalizedProduct(source="coupang", name=name, price=price, mall_name=mall) + + +def test_top_n_is_lowest_price_sorted(): + prods = [_p(x) for x in [3000, 1000, 2000, 5000, 4000]] + r = run_price_pipeline(prods, remove_outliers=False, top_n=3) + assert [p["price"] for p in r["top"]] == [1000, 2000, 3000] + assert r["lowest"]["price"] == 1000 + + +def test_outlier_removes_extreme_low_and_high(): + normal = [_p(x) for x in [1000, 1010, 1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090]] + kept, removed = remove_price_outliers(normal + [_p(5), _p(500000)]) + prices_removed = {p.price for p in removed} + assert 5 in prices_removed and 500000 in prices_removed + assert all(1000 <= p.price <= 1090 for p in kept) + + +def test_price_band_filter(): + prods = [_p(x) for x in [8000, 10000, 12000, 30000, 1000]] + # 기준가 10000, ±70% → [3000, 17000] 만 통과 + out = filter_by_price_band(prods, base_price=10000, tolerance=0.7) + assert {p.price for p in out} == {8000, 10000, 12000} + + +def test_filter_out_malls(): + prods = [_p(1000, mall="쿠팡"), _p(2000, mall="G마켓"), _p(3000, mall="옥션")] + out = filter_out_malls(prods, ["G마켓", "옥션"]) + assert {p.mall_name for p in out} == {"쿠팡"} + + +def test_empty_input_is_safe(): + r = run_price_pipeline([], top_n=5) + assert r["lowest"] is None and r["top"] == [] and r["total_found"] == 0 + + +def test_stages_recorded(): + prods = [_p(x) for x in [1000, 2000, 3000, 4000, 5000]] + r = run_price_pipeline(prods, base_price=3000, top_n=2) + names = [s["stage"] for s in r["stages"]] + assert "price_band" in names and "outlier" in names and names[-1] == "top_n" + assert r["stages"][-1]["out"] == 2 # top_n 결과 수 + + +def _pm(source, mall, price): + return NormalizedProduct(source=source, name=f"{mall}상품", price=price, mall_name=mall) + + +def test_summarize_by_mall_lowest_per_mall_sorted(): + # 같은 몰 여러 건 → 몰별 최저가만, 전체 가격 오름차순 + prods = [ + _pm("naver", "G마켓", 12000), _pm("naver", "G마켓", 11000), + _pm("naver", "11번가", 10500), _pm("naver", "네이버", 9800), + _pm("coupang", "쿠팡", 10200), + ] + rows = summarize_by_mall(prods) + # 몰별 최저가 1건씩, 전체 가격 오름차순 + assert [(r["mall_name"], r["price"]) for r in rows] == [ + ("네이버", 9800), ("쿠팡", 10200), ("11번가", 10500), ("G마켓", 11000), + ] + + +def test_summarize_by_mall_in_result(): + r = run_price_pipeline([_pm("naver", "11번가", 5000), _pm("naver", "G마켓", 6000)], remove_outliers=False) + malls = {row["mall_name"] for row in r["by_mall"]} + assert malls == {"11번가", "G마켓"} + + +def test_pipeline_on_real_fixture(): + products = parse_search_html(FIXTURE.read_text()) + r = run_price_pipeline(products, remove_outliers=False, top_n=3) + prices = [p["price"] for p in r["top"]] + assert prices == sorted(prices) # 최저가순 + assert r["lowest"]["price"] == min(p.price for p in products) diff --git a/lps/tests/test_pool_autosize.py b/lps/tests/test_pool_autosize.py new file mode 100644 index 0000000..3d780b3 --- /dev/null +++ b/lps/tests/test_pool_autosize.py @@ -0,0 +1,57 @@ +"""커넥션 풀 자동 산정(_autosize_pool) — process_count 기준으로 예산을 넘지 않아야 한다.""" + +import pytest + +from config.config_models import MainDBConfig +from config.server_configs import _autosize_pool + + +def _conns(cfg: MainDBConfig, pc: int) -> int: + # 실제 동시 커넥션 = (pool + overflow) × 2엔진(R/W) × process_count + return (cfg.pool_size + cfg.max_overflow) * 2 * pc + + +@pytest.mark.parametrize("pc", [1, 2, 4, 8, 16]) +def test_autosize_within_budget(pc): + cfg = MainDBConfig(connection_budget=40) + _autosize_pool(cfg, pc) + assert _conns(cfg, pc) <= 40 + assert cfg.pool_size >= 1 + assert cfg.max_overflow >= 0 + + +def test_autosize_uses_budget_efficiently(): + # 예산을 지나치게 낭비하지 않아야(넉넉한 예산일 때 절반 이상 활용) + cfg = MainDBConfig(connection_budget=96) + _autosize_pool(cfg, 4) + assert _conns(cfg, 4) <= 96 + assert _conns(cfg, 4) >= 96 // 2 + + +def test_autosize_split_ratio(): + # pool(정상) 이 overflow(버스트)보다 크거나 같게 분할 + cfg = MainDBConfig(connection_budget=96) + _autosize_pool(cfg, 1) + assert cfg.pool_size >= cfg.max_overflow + + +def test_autosize_disabled_when_budget_zero(): + # budget<=0 이면 자동 산정 끔 → toml/기본 pool 값 유지, None 반환 + cfg = MainDBConfig(connection_budget=0, pool_size=10, max_overflow=20) + assert _autosize_pool(cfg, 4) is None + assert (cfg.pool_size, cfg.max_overflow) == (10, 20) + + +def test_autosize_returns_computed_pair(): + cfg = MainDBConfig(connection_budget=40) + result = _autosize_pool(cfg, 2) + assert result == (cfg.pool_size, cfg.max_overflow) + + +def test_autosize_infeasible_process_count_floors_at_one(): + # process_count×2 > budget 이면 예산 준수가 물리적으로 불가능(워커당 최소 1커넥션 필요). + # 이때는 pool_size=1/overflow=0(엔진당 1커넥션)까지 줄이는 게 한계 — 0 풀은 만들지 않는다. + cfg = MainDBConfig(connection_budget=40) + _autosize_pool(cfg, 64) + assert cfg.pool_size == 1 + assert cfg.max_overflow == 0 diff --git a/lps/tests/test_price_history.py b/lps/tests/test_price_history.py new file mode 100644 index 0000000..00def63 --- /dev/null +++ b/lps/tests/test_price_history.py @@ -0,0 +1,93 @@ +"""최저가 이력 — 소스별 min 스냅샷 계산 + 기록/조회 CRUD + 핸들러 기록 규칙.""" + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import JobType +from crud.price_history import PriceHistory +from services.search.contract import NormalizedProduct +from worker.handlers import _price_snapshot, build_search_handler + + +def _np(source, price, name=None): + return NormalizedProduct(source=source, name=name or f"{source}-{price}", price=price, detail_url=f"http://{source}/{price}") + + +# ── 스냅샷 계산 (순수) ───────────────────────────────────────────── +def test_snapshot_source_lowest_and_final(): + matched = [_np("naver", 3000), _np("naver", 2000), _np("coupang", 2500)] + s = _price_snapshot(matched) + assert s["naver_lowest"] == 2000 and s["coupang_lowest"] == 2500 + assert s["final_lowest"] == 2000 and s["final_source"] == "naver" + assert s["matched_count"] == 3 + + +def test_snapshot_single_source_only(): + s = _price_snapshot([_np("coupang", 1500)]) + assert s["naver_lowest"] is None and s["coupang_lowest"] == 1500 + assert s["final_lowest"] == 1500 and s["final_source"] == "coupang" + + +def test_snapshot_empty_is_all_null(): + s = _price_snapshot([]) + assert s["final_lowest"] is None and s["naver_lowest"] is None and s["matched_count"] == 0 + + +# ── CRUD (실 DB) ─────────────────────────────────────────────────── +@pytest_asyncio.fixture +async def ph(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE price_history")) + return PriceHistory() + + +async def test_record_and_list_time_ordered(ph): + await ph.record({"product_code": "P1", "outcome": "found", "final_lowest": 2000, "final_source": "naver", + "naver_lowest": 2000, "coupang_lowest": 2500, "matched_count": 3}) + await ph.record({"product_code": "P1", "outcome": "found", "final_lowest": 1900, "final_source": "coupang", + "naver_lowest": 2100, "coupang_lowest": 1900, "matched_count": 2}) + await ph.record({"product_code": "P2", "outcome": "found", "final_lowest": 999}) # 다른 상품 + + points = await ph.list_by_product("P1") + assert len(points) == 2 # P2 제외 + assert [p["final_lowest"] for p in points] == [2000, 1900] # 시각 오름차순 + assert points[0]["triggered_at"] <= points[1]["triggered_at"] + + +# ── 핸들러 기록 규칙 ─────────────────────────────────────────────── +class _Rec: + def __init__(self): self.events = [] + async def record(self, e): self.events.append(e) + + +class _FakeAdapter: + def __init__(self, source, products): self.source = source; self._p = products + async def search(self, q, limit=40): return self._p + + +class _Neg: + def __init__(self, neg): self._neg = neg + async def is_negative(self, k): return self._neg + async def put(self, *a, **k): pass + + +def _job(**p): + p.setdefault("product_name", "x"); p.setdefault("product_code", "PC1") + return {"job_type": JobType.SEARCH.value, "attempts": 1, "job_id": "j1", "payload": p} + + +async def test_handler_records_found_snapshot(): + rec = _Rec() + adapters = {"naver": _FakeAdapter("naver", [_np("naver", 2000)]), "coupang": _FakeAdapter("coupang", [_np("coupang", 1800)])} + await build_search_handler(adapters, history=rec)(_job()) + assert len(rec.events) == 1 + e = rec.events[0] + assert e["product_code"] == "PC1" and e["outcome"] == "found" + assert e["final_lowest"] == 1800 and e["final_source"] == "coupang" + + +async def test_handler_skips_record_on_negative_cache_hit(): + rec = _Rec() + adapters = {"naver": _FakeAdapter("naver", [_np("naver", 100)])} + await build_search_handler(adapters, neg_cache=_Neg(True), history=rec)(_job()) + assert rec.events == [] # 캐시 히트 → 새 관측 없음 → 미기록 diff --git a/lps/tests/test_proxy.py b/lps/tests/test_proxy.py new file mode 100644 index 0000000..49cf61e --- /dev/null +++ b/lps/tests/test_proxy.py @@ -0,0 +1,52 @@ +"""DECODO 프록시 제공자 테스트 (포트 기반 sticky, 순수·네트워크 불필요).""" + +from config.config_models import DecodoConfig +from services.search.proxy import DecodoProxy + + +def _p(**kw): + base = dict(host="gate.decodo.com", username="user1", password="pw", port_start=10001, port_end=10010, session_minutes=10) + base.update(kw) + return DecodoProxy(DecodoConfig(**base)) + + +def test_disabled_when_credentials_missing(): + assert DecodoProxy(DecodoConfig()).enabled is False # 전부 비어있음 + assert _p(password="").enabled is False + assert _p(port_end=0).enabled is False # 포트 미설정 + assert _p().enabled is True + + +def test_playwright_proxy_shape(): + cfg = _p().playwright_proxy() + assert cfg["username"] == "user1" and cfg["password"] == "pw" # 고정 자격증명 + assert cfg["server"].startswith("http://gate.decodo.com:") + port = int(cfg["server"].rsplit(":", 1)[1]) + assert 10001 <= port <= 10010 # 포트 범위 안 + + +def test_disabled_returns_none(): + assert DecodoProxy(DecodoConfig()).playwright_proxy() is None + + +def test_port_selected_within_range_and_stable_in_window(): + p = _p() + port = p._port() + assert 10001 <= port <= 10010 + assert p._port() == port # 같은 시간창에서는 동일 포트(동일 sticky IP) + + +def test_single_port_range(): + p = _p(port_start=10001, port_end=10001) + assert p._port() == 10001 # 포트 1개면 항상 그 포트 + + +def test_rotate_advances_port_immediately(): + p = _p(port_start=10001, port_end=10003) # 포트 3개 + before = p._port() + p.rotate() + after = p._port() + assert after != before # 즉시 다음 포트(새 IP) + assert 10001 <= after <= 10003 + p.rotate(); p.rotate() # 3번 회전하면 한 바퀴 → 원위치 + assert p._port() == before diff --git a/lps/tests/test_search_handler.py b/lps/tests/test_search_handler.py new file mode 100644 index 0000000..63f782a --- /dev/null +++ b/lps/tests/test_search_handler.py @@ -0,0 +1,241 @@ +"""검색 핸들러 테스트 — 병합·실패격리·AI판정·재정제 루프·not_found·네거티브 캐시 (fake 의존성).""" + +import asyncio + +import pytest + +from common.enums import JobType +from services.search.contract import NormalizedProduct, AdapterError +from worker.handlers import build_search_handler, _abandoned_fallbacks + + +class FakeAdapter: + def __init__(self, source, by_query=None, products=None, fail=False, uses_proxy=False, last_bytes=0, delay=0.0): + self.source = source + self._by_query = by_query # {query: [products]} + self._products = products or [] + self._fail = fail + self._delay = delay # search 지연(초) — 데드라인 테스트용 + self.uses_proxy = uses_proxy # DECODO 경유 여부(비용 귀속) + self.last_bytes = last_bytes + self.calls = [] + + async def search(self, query, limit=40): + self.calls.append(query) + if self._delay: + await asyncio.sleep(self._delay) + if self._fail: + raise AdapterError("boom", source=self.source, blocked=True) + if self._by_query is not None: + return self._by_query.get(query, []) + return self._products + + +class _Usage: + def __init__(self, prompt, completion): + self.prompt_tokens, self.completion_tokens = prompt, completion + + +class FakeJudge: + def __init__(self, predicate, usage=None): + self._pred = predicate + self.last_usage = usage # 계측용(핸들러가 judge 후 읽음) + + async def judge(self, target, candidates): + from services.ai.similarity import Judgment + return [Judgment(index=i + 1, is_match=self._pred(c), score=100 if self._pred(c) else 0) + for i, c in enumerate(candidates)] + + +class FakeKeywordGen: + def __init__(self, precise="", broad=""): + self._p, self._b = precise, broad + self.last_usage = None + + async def generate(self, target): + from services.ai.keyword import Keywords + return Keywords(precise=self._p, broad=self._b) + + +class FakeNegCache: + def __init__(self, negative=False): + self._neg = negative + self.puts = [] + + async def is_negative(self, key): + return self._neg + + async def put(self, key, ttl_sec=86400, reason="x"): + self.puts.append(key) + + +def _np(source, price, mall=None): + return NormalizedProduct(source=source, name=f"{source}-{price}", price=price, mall_name=mall) + + +def _job(**payload): + payload.setdefault("product_name", "x") + return {"job_type": JobType.SEARCH.value, "attempts": 1, "payload": payload} + + +# ── 병합 / 실패격리 / AI 판정 (round 0) ───────────────────────────── +async def test_merges_and_ranks_across_sources(): + adapters = { + "coupang": FakeAdapter("coupang", products=[_np("coupang", 3000), _np("coupang", 1000)]), + "naver": FakeAdapter("naver", products=[_np("naver", 2000), _np("naver", 500)]), + } + r = await build_search_handler(adapters, top_n=3)(_job()) + assert r["outcome"] == "found" and r["lowest"]["price"] == 500 + assert [p["price"] for p in r["top"]] == [500, 1000, 2000] + + +async def test_isolates_single_source_failure_but_still_found(): + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", products=[_np("naver", 900)])} + r = await build_search_handler(adapters)(_job()) + assert r["outcome"] == "found" and r["lowest"]["price"] == 900 + assert "error" in r["sources"]["coupang"] + + +async def test_ai_judge_filters_non_matches(): + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 1000), _np("naver", 2000), _np("naver", 3000)])} + r = await build_search_handler(adapters, judge=FakeJudge(lambda c: c.price == 2000))(_job()) + assert [p["price"] for p in r["top"]] == [2000] + assert next(s for s in r["stages"] if s["stage"] == "ai_match")["out"] == 1 + + +# ── 재정제 루프 ──────────────────────────────────────────────────── +async def test_refines_to_precise_query_when_original_empty(): + adapters = {"naver": FakeAdapter("naver", by_query={"스탠리 퀜처 887ml": [_np("naver", 40000)]})} # 원본은 0건 + kw = FakeKeywordGen(precise="스탠리 퀜처 887ml", broad="스탠리 텀블러") + r = await build_search_handler(adapters, keyword_gen=kw)(_job(product_name="스탠리 텀블러")) + assert r["outcome"] == "found" and r["round"] == "precise" and r["rounds_tried"] == 2 + assert r["lowest"]["price"] == 40000 + + +async def test_not_found_after_all_rounds_and_caches(): + adapters = {"naver": FakeAdapter("naver", by_query={})} # 어떤 쿼리든 0건 + kw = FakeKeywordGen(precise="P", broad="B") + neg = FakeNegCache() + r = await build_search_handler(adapters, keyword_gen=kw, neg_cache=neg)(_job(product_code="PC1", product_name="없는상품")) + assert r["outcome"] == "not_found" and r["rounds_tried"] == 3 + assert r["lowest"] is None and r["top"] == [] + assert neg.puts == ["PC1"] # 네거티브 캐시에 기록 + + +async def test_negative_cache_short_circuits(): + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 100)])} + neg = FakeNegCache(negative=True) + r = await build_search_handler(adapters, neg_cache=neg)(_job(product_code="PC9")) + assert r["outcome"] == "not_found" and r["cached"] is True + assert adapters["naver"].calls == [] # 재검색 안 함 + + +async def test_technical_failure_with_zero_match_raises(): + adapters = {"coupang": FakeAdapter("coupang", fail=True), "naver": FakeAdapter("naver", by_query={})} + with pytest.raises(RuntimeError): + await build_search_handler(adapters)(_job()) # 0매칭 + 차단 → 기술 재시도 + + +# ── 오픈마켓 폴백 크롤 (네이버 미커버 몰만) ────────────────────────── +async def test_fallback_crawls_only_uncovered_malls(): + # 네이버 매칭에 G마켓은 있고(→크롤 생략), 11번가는 없음(→크롤). 옥션도 없음(→크롤). + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="G마켓")])} + gmarket = FakeAdapter("gmarket", products=[_np("gmarket", 4000, mall="G마켓")]) + auction = FakeAdapter("auction", products=[_np("auction", 4500, mall="옥션")]) + st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")]) + handler = build_search_handler( + adapters, judge=FakeJudge(lambda c: True), + fallback_adapters={"gmarket": gmarket, "auction": auction, "st11": st11}, + ) + r = await handler(_job()) + assert gmarket.calls == [] # 네이버가 G마켓 커버 → 크롤 생략 + assert auction.calls and st11.calls # 미커버 → 크롤함 + assert r["lowest"]["price"] == 3000 # 11번가 크롤가가 전체 최저 + malls = {m["mall_name"] for m in r["by_mall"]} + assert malls == {"G마켓", "옥션", "11번가"} # 네이버 G마켓 + 크롤 옥션·11번가 + + +async def test_fallback_deadline_skips_slow_mall(): + # 느린 폴백(데드라인 초과)은 스킵되고, 빠른 폴백은 병합된다 — 전체 지연에 상한. + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 9000, mall="네이버")])} + slow = FakeAdapter("gmarket", products=[_np("gmarket", 1000, mall="G마켓")], delay=1.0) # 데드라인 초과 + fast = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")], delay=0.0) + r = await build_search_handler( + adapters, judge=FakeJudge(lambda c: True), + fallback_adapters={"gmarket": slow, "st11": fast}, + fallback_deadline_sec=0.2, + )(_job()) + malls = {m["mall_name"] for m in r["by_mall"]} + assert "11번가" in malls # 빠른 폴백 병합됨 + assert "G마켓" not in malls # 느린 폴백은 데드라인 초과로 스킵 + assert r["lowest"]["price"] == 3000 # G마켓 1000은 스킵됐으므로 최저가 아님 + # 버려진 크롤은 cancel 없이 백그라운드 종료된다 — 루프 닫기 전에 배수(pending 태스크 파괴 경고 방지) + assert _abandoned_fallbacks # 느린 폴백이 버려짐 + await asyncio.gather(*_abandoned_fallbacks, return_exceptions=True) + assert not _abandoned_fallbacks # 종료 콜백이 집합에서 제거함 + + +async def test_fallback_failure_is_isolated(): + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 9000, mall="네이버")])} + st11 = FakeAdapter("st11", fail=True) # 크롤 실패 + r = await build_search_handler( + adapters, judge=FakeJudge(lambda c: True), + fallback_adapters={"st11": st11}, + )(_job()) + assert r["outcome"] == "found" and r["lowest"]["price"] == 9000 # 폴백 실패해도 정상 종료 + + +# ── 검색 원가 계측(metrics) ──────────────────────────────────────── +async def test_metrics_recorded_in_result(): + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 1000), _np("naver", 2000)])} + adapters["naver"].last_bytes = 1234 + judge = FakeJudge(lambda c: True, usage=_Usage(500, 40)) + r = await build_search_handler(adapters, judge=judge, ai_model="gpt-4o-mini")(_job()) + m = r["metrics"] + assert m["ai"]["calls"] == 1 and m["ai"]["prompt_tokens"] == 500 and m["ai"]["completion_tokens"] == 40 + assert m["ai"]["est_cost_usd"] == round(500/1e6*0.15 + 40/1e6*0.60, 6) # gpt-4o-mini 단가 + assert m["crawl"]["fetches"] == 1 and m["crawl"]["html_bytes"] == 1234 + assert "naver" in m["source_ms"] and "duration_ms" in m + + +async def test_metrics_cost_split_ai_and_proxy(): + # 네이버(직접, 프록시X) + 프록시 경유 크롤 폴백 → proxy_usd 는 프록시 바이트만, ai_usd 는 토큰만 + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="네이버")], last_bytes=2000)} + st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")], uses_proxy=True, last_bytes=1024**3) # 1GB + judge = FakeJudge(lambda c: True, usage=_Usage(1_000_000, 0)) # 1M prompt 토큰 + handler = build_search_handler( + adapters, judge=judge, ai_model="gpt-4o-mini", + fallback_adapters={"st11": st11}, proxy_cost_per_gb=3.0, + ) + m = (await handler(_job()))["metrics"] + # 네이버 2000B 는 프록시 경유 아님 → proxy_bytes = 1GB(st11)만 + assert m["crawl"]["proxy_bytes"] == 1024**3 + assert m["cost"]["proxy_usd"] == 3.0 # 1GB × $3 + assert m["cost"]["ai_usd"] == round(m["ai"]["prompt_tokens"]/1e6*0.15, 6) # gpt-4o-mini input 단가 + assert m["cost"]["total_usd"] == round(m["cost"]["ai_usd"] + 3.0, 6) + + +async def test_metrics_counts_fallback_crawl(): + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 5000, mall="네이버")])} + st11 = FakeAdapter("st11", products=[_np("st11", 3000, mall="11번가")]) + st11.last_bytes = 9999 + r = await build_search_handler( + adapters, judge=FakeJudge(lambda c: True), + fallback_adapters={"st11": st11}, + )(_job()) + m = r["metrics"] + assert m["crawl"]["fetches"] == 2 # naver + st11 크롤 + assert "st11" in m["crawl"]["malls_crawled"] # 폴백 크롤 몰 기록 + assert m["crawl"]["html_bytes"] == 9999 # st11 바이트 포함 + + +async def test_fallback_dedup_same_mall_keeps_lowest(): + # 네이버 매칭에 G마켓 없음 → 크롤. 크롤 G마켓이 네이버 '네이버몰'보다 싸면 최저가 갱신. + adapters = {"naver": FakeAdapter("naver", products=[_np("naver", 8000, mall="네이버")])} + gmarket = FakeAdapter("gmarket", products=[_np("gmarket", 6000, mall="G마켓"), _np("gmarket", 7000, mall="G마켓")]) + r = await build_search_handler( + adapters, judge=FakeJudge(lambda c: True), + fallback_adapters={"gmarket": gmarket}, + )(_job()) + gm = [m for m in r["by_mall"] if m["mall_name"] == "G마켓"] + assert len(gm) == 1 and gm[0]["price"] == 6000 # 몰별 1건(최저)로 dedup diff --git a/lps/tests/test_worker.py b/lps/tests/test_worker.py new file mode 100644 index 0000000..e4b7ac8 --- /dev/null +++ b/lps/tests/test_worker.py @@ -0,0 +1,139 @@ +"""워커 루프 테스트 — drain→DONE, 실패→재시도→dead, reaper 회수 후 재처리, NOTIFY 깨움. +핸들러는 fake(브라우저 없이) — 워커 로직만 결정론적으로 검증한다.""" + +import asyncio + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import JobStatus, JobType +from crud.job_crud import JobQueue +from worker.notify import JobListener +from worker.runner import Worker + + +@pytest_asyncio.fixture +async def q(db_engine): + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE job")) + return JobQueue() + + +async def test_worker_drains_all_to_done(q): + for i in range(3): + await q.enqueue(JobType.SEARCH.value, {"product_name": f"item{i}"}) + + async def handler(job): + return {"ok": True, "q": job["payload"]["product_name"]} + + processed = await Worker("w1", q, handler).drain() + assert processed == 3 + assert (await q.counts())["DONE"] == 3 + + +async def test_worker_failure_retries_then_dead(q): + await q.enqueue(JobType.SEARCH.value, {"product_name": "x"}, max_attempts=2) + + async def boom(job): + raise RuntimeError("nope") + + w = Worker("w1", q, boom, backoff_fn=lambda a: 0) # 백오프 0 → 즉시 재시도 가능 + assert await w.process_one() is True # 1/2 실패 → PENDING + assert (await q.counts())["PENDING"] == 1 + assert await w.process_one() is True # 2/2 실패 → DEAD + counts = await q.counts() + assert counts["DEAD"] == 1 and counts["PENDING"] == 0 + assert await w.process_one() is False # DEAD 는 claim 대상 아님 + + +async def test_reaper_reclaims_then_worker_reprocesses(q): + jid = await q.enqueue(JobType.SEARCH.value, {"product_name": "x"}) + await q.claim("dead-worker", lease_sec=1) # 점유 후 사망 흉내 + await asyncio.sleep(1.3) + assert jid in await q.reap() # 회수 → PENDING + + async def handler(job): + return {"ok": True} + + assert await Worker("w2", q, handler).process_one() is True + assert (await q.counts())["DONE"] == 1 + + +async def test_job_deadline_cancels_hung_handler(q): + """핸들러 행 → 데드라인 초과 시 취소·fail 처리(재큐/DEAD)돼야 한다. 없으면 heartbeat 가 + lease 를 계속 갱신해 워커 슬롯이 영구 점유된다(2026-07-10 부하테스트 실측).""" + jid = await q.enqueue(JobType.SEARCH.value, {"product_name": "hang"}, max_attempts=1) + + async def hang(job): + await asyncio.sleep(3600) + + w = Worker("w1", q, hang, backoff_fn=lambda a: 0, job_deadline_sec=0.2) + assert await w.process_one() is True # 행이어도 데드라인에 끊겨 반환된다 + assert (await q.counts())["DEAD"] == 1 # max_attempts=1 → 즉시 DEAD + job = await q.get(jid) + assert "JobDeadlineExceeded" in job["last_error"] + + +async def test_job_deadline_retries_before_dead(q): + """데드라인 초과도 일반 실패처럼 백오프 재큐를 탄다(시도 소진 전까지).""" + await q.enqueue(JobType.SEARCH.value, {"product_name": "hang"}, max_attempts=2) + + async def hang(job): + await asyncio.sleep(3600) + + w = Worker("w1", q, hang, backoff_fn=lambda a: 0, job_deadline_sec=0.2) + assert await w.process_one() is True + assert (await q.counts())["PENDING"] == 1 # 1/2 → 재큐 + assert await w.process_one() is True + assert (await q.counts())["DEAD"] == 1 # 2/2 → DEAD + + +async def test_ops_counts_long_running_as_stuck(q, db_engine): + """lease 가 계속 갱신돼도(행 상태의 heartbeat) 실행 10분 초과면 stuck_running 에 잡혀야 한다.""" + await q.enqueue(JobType.SEARCH.value, {"product_name": "x"}) + await q.claim("w1", lease_sec=3600) # lease 는 멀쩡(만료 안 됨) + assert (await q.ops())["stuck_running"] == 0 + async with db_engine.begin() as conn: + await conn.execute(text("UPDATE job SET run_started_at = now() - interval '11 minutes' WHERE status = 2")) + assert (await q.ops())["stuck_running"] == 1 + + +async def test_browser_reaper_survives_hung_adapter(): + """한 어댑터의 close 행이 정리 루프 전체를 멈추면 안 된다 — 타임아웃 후 다음 어댑터로.""" + from worker_main import run_browser_reaper + + class HungAdapter: + source = "hung" + async def close_if_idle(self, idle_sec): + await asyncio.sleep(3600) + + class OkAdapter: + source = "ok" + closed = False + async def close_if_idle(self, idle_sec): + self.closed = True + + ok = OkAdapter() + stop = asyncio.Event() + task = asyncio.create_task(run_browser_reaper( + [HungAdapter(), ok], stop, idle_sec=0, interval=0.01, close_timeout=0.05)) + try: + await asyncio.wait_for(_until(lambda: ok.closed), timeout=3.0) # 행 어댑터를 지나 ok 까지 도달 + finally: + stop.set() + await task + + +async def _until(cond, poll: float = 0.02): + while not cond(): + await asyncio.sleep(poll) + + +async def test_enqueue_notifies_listener(q): + listener = JobListener() + await listener.start() + try: + await q.enqueue(JobType.SEARCH.value, {"product_name": "x"}) # pg_notify 발생 + assert await listener.wait(3.0) is True # 즉시 깨어남 + finally: + await listener.close() diff --git a/lps/web_main.py b/lps/web_main.py new file mode 100644 index 0000000..6f94403 --- /dev/null +++ b/lps/web_main.py @@ -0,0 +1,46 @@ +# 실행 방법 +# pip install -r requirements.txt +# python web_main.py # 기본 local 환경 +# APP_ENV=dev python web_main.py # 환경 지정 +# +# 또는 uvicorn 직접 실행: +# uvicorn router.router:app --reload --host=0.0.0.0 --port=9600 + +import uvicorn + +from common.logger import LOG +from config.server_configs import web_server_config, main_db_config + +LOG.SetPrefix(web_server_config.server_name) + +# import 시점에 app 및 DB 세션 매니저(싱글톤)가 초기화된다. +import router.router + +if __name__ == "__main__": + LOG.i(f"Server Name : {web_server_config.server_name}") + LOG.i(f"Server Port : {web_server_config.port}") + LOG.i(f"API Server start time : {router.router.API_SERVER_START_TIME}") + # 실효 커넥션 풀(자동 산정 결과) — 멀티워커 시 커넥션 예산 준수 여부 확인용. + _pc = web_server_config.process_count + _conn = (main_db_config.pool_size + main_db_config.max_overflow) * 2 * _pc + LOG.i(f"DB Pool : pool_size={main_db_config.pool_size} max_overflow={main_db_config.max_overflow} " + f"× 2engine × {_pc}workers = {_conn} conns (budget={main_db_config.connection_budget})") + + if web_server_config.is_ssl: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ssl_keyfile="./SSL/key.pem", + ssl_certfile="./SSL/cert.pem", + ) + else: + uvicorn.run( + "router.router:app", + host="0.0.0.0", + port=web_server_config.port, + access_log=False, + workers=web_server_config.process_count, + ) diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py new file mode 100644 index 0000000..21be864 --- /dev/null +++ b/lps/worker/handlers.py @@ -0,0 +1,241 @@ +"""잡 핸들러 — job_type 별 처리. 현재는 SEARCH(검색)만. + +검색 핸들러 = 한정된 재정제 루프 + 명시적 outcome: + 0. 네거티브 캐시 확인(최근 not_found면 즉시 반환) + 각 라운드(원본 → 정밀(LLM) → 광역, 최대 max_rounds): + 소스 동시 검색 → 필터 → 이상치 → AI 같은상품 판정 + ├ 매칭 있음 → DONE(outcome=found), 조기 종료 + ├ 0매칭 + 기술적 실패(차단/예외) 있음 → raise → 큐가 잡 전체 백오프 재시도(→소진 시 DEAD) + └ 0매칭 + 소스 정상 → 다음 라운드 + 라운드 소진 → DONE(outcome=not_found) + 네거티브 캐시 기록 + +두 재시도 축을 분리한다: 기술적(큐 attempts/백오프) ≠ 검색어(refine 라운드, 유한). +'못 찾음'은 정상 종료(DONE)지 dead-letter 가 아니다. +""" + +import asyncio +import time + +from common.enums import JobType +from common.logger import LOG +from services.metrics import SearchMetrics +from services.search.contract import SearchAdapter, NormalizedProduct +from services.search.card_parser import canonical_mall, MALL_BY_SOURCE +from services.search.util import parse_price +from services.pipeline.core import apply_filters, rank_result, summarize_by_mall + + +# 데드라인 초과로 버린 폴백 태스크의 강한 참조(asyncio 는 태스크를 약참조만 유지 — 없으면 GC 로 중도 파괴될 수 있음). +# 완료 시 콜백에서 스스로 제거된다. 테스트는 이 집합을 gather 해 잔여 태스크를 배수(drain)할 수 있다. +_abandoned_fallbacks: set[asyncio.Task] = set() + + +def _reap_abandoned(task: asyncio.Task): + """버려진 폴백 태스크 종료 시 예외를 회수 — 'Future exception was never retrieved' 노이즈 방지.""" + _abandoned_fallbacks.discard(task) + if task.cancelled(): + return + ex = task.exception() + if ex is not None: + LOG.d(f"[fallback] 데드라인 초과 태스크 종료(무시): {type(ex).__name__}") + + +def _price_snapshot(matched: list[NormalizedProduct]) -> dict: + """매칭 목록에서 소스별 최저가 + 전체 최저가 스냅샷을 만든다(price_history 기록용).""" + def lowest(src): + items = [p for p in matched if p.source == src] + return min(items, key=lambda p: p.price) if items else None + + n, c = lowest("naver"), lowest("coupang") + # 최종 최저가는 소스 무관 전체 매칭 중 최저(G마켓·옥션·11번가 등 폴백 포함). + f = min(matched, key=lambda p: p.price) if matched else None + return { + "matched_count": len(matched), + "naver_lowest": n.price if n else None, "naver_name": n.name if n else None, "naver_url": n.detail_url if n else None, + "coupang_lowest": c.price if c else None, "coupang_name": c.name if c else None, "coupang_url": c.detail_url if c else None, + "final_lowest": f.price if f else None, "final_source": f.source if f else None, + "by_mall": summarize_by_mall(matched), # 몰별 최저가 스냅샷(열린 스키마) + } + + +def build_search_handler( + adapters: dict[str, SearchAdapter], + sources: list[str] | None = None, + limit: int = 40, + top_n: int = 5, + judge=None, + keyword_gen=None, + max_rounds: int = 3, + neg_cache=None, + history=None, + fallback_adapters: dict[str, SearchAdapter] | None = None, + ai_model: str = "", + proxy_cost_per_gb: float = 0.0, + fallback_deadline_sec: float = 15.0, +): + """검색 핸들러 생성. + judge: SimilarityJudge(같은 상품 판정) / keyword_gen: KeywordGenerator(정밀·광역 재검색어) / + neg_cache: NegativeCache(TTL not_found 캐시) / history: PriceHistory(최저가 스냅샷) / + fallback_adapters: 오픈마켓 크롤(gmarket/auction/st11) — 네이버가 그 몰을 커버 못 했을 때만 크롤(폴백). + 모두 선택 — 없으면 해당 단계 생략.""" + use = list(sources) if sources else list(adapters.keys()) + fallbacks = fallback_adapters or {} + + async def _record_history(product_code: str, job_id, outcome: str, matched: list): + if history is None: + return + event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, **_price_snapshot(matched)} + try: + await history.record(event) + except Exception as ex: + LOG.e_no_callstack(f"[history] 스냅샷 기록 실패(무시): {ex}") + + async def _timed_search(adapter, query: str, source: str, metrics: SearchMetrics, crawl: bool): + """어댑터 검색 1건을 타이밍+바이트 계측하며 실행. 예외는 그대로 전파(호출부가 처리).""" + via_proxy = bool(getattr(adapter, "uses_proxy", False)) + t0 = time.monotonic() + try: + res = await adapter.search(query, limit=limit) + metrics.add_fetch(source, getattr(adapter, "last_bytes", 0), int((time.monotonic() - t0) * 1000), crawl=crawl, via_proxy=via_proxy) + return res + except BaseException: # CancelledError(데드라인 취소) 포함 — 소요/바이트는 계측하고 재전파 + metrics.add_fetch(source, getattr(adapter, "last_bytes", 0), int((time.monotonic() - t0) * 1000), crawl=crawl, via_proxy=via_proxy) + raise + + async def _search_round(query: str, metrics: SearchMetrics): + """한 라운드: 모든 소스 동시 검색 → (products, per_source, tech_failed). 소스별 시간/바이트 계측.""" + results = await asyncio.gather(*[_timed_search(adapters[s], query, s, metrics, False) for s in use], + return_exceptions=True) + products, per_source, tech_failed = [], {}, False + for src, res in zip(use, results): + if isinstance(res, Exception): + tech_failed = True + per_source[src] = {"error": f"{type(res).__name__}: {res}"} + LOG.w(f"[{src}] 검색 실패: {type(res).__name__}: {res}") + else: + products.extend(res) + per_source[src] = {"count": len(res)} + return products, per_source, tech_failed + + async def _match(target: dict, products: list, base_price, metrics: SearchMetrics): + """필터 → (있으면) AI 같은상품 판정 → 매칭 후보. AI 토큰은 metrics 에 누적. + ⚠️ 동시성 불변식: judge.judge 가 last_usage 를 세팅한 뒤 여기서 읽기까지 await 이 없어야 + 한다(asyncio 협조 스케줄링상 그 사이 다른 코루틴이 last_usage 를 덮어쓸 수 없음). 병렬 폴백 안전.""" + candidates, _ = apply_filters(products, base_price=base_price) + if judge is not None and candidates: + verdicts = await judge.judge(target, candidates) + metrics.add_ai(judge.last_usage) # ← await 직후 즉시 읽음(사이에 await 금지) + candidates = [c for c, v in zip(candidates, verdicts) if v.is_match] + return candidates + + async def _enrich_with_fallback(target: dict, query: str, matched: list, base_price, metrics: SearchMetrics): + """네이버가 커버 못 한 오픈마켓만 직접 크롤(폴백) → 같은상품 판정 후 병합. + 사용자 규칙: '네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 실사이트 크롤'. + 미커버 몰들을 **동시 크롤**한다(각 어댑터=별 브라우저 인스턴스라 병렬 안전, 소요=합→최댓값).""" + if not fallbacks: + return matched + covered = {canonical_mall(p) for p in matched} + todo = [(src, ad) for src, ad in fallbacks.items() if MALL_BY_SOURCE.get(src, src) not in covered] + if not todo: + return matched + + async def _crawl_match(src, adapter): + # 폴백은 '있으면 좋은' 보강이라 데드라인을 건다 — 초과 시 그 몰만 스킵(전체 지연에 상한). + # cancel 하지 않고 버린다(asyncio.wait): in-flight page.goto 를 취소하면 patchright 내부 + # future 가 미회수 예외 노이즈를 남기고 페이지가 어중간한 상태로 남는다. 버려진 크롤은 + # 백그라운드에서 자체 타임아웃(goto 40s 등)으로 끝나고 _reap_abandoned 가 예외를 회수한다. + task = asyncio.ensure_future(_timed_search(adapter, query, src, metrics, crawl=True)) + done, _ = await asyncio.wait({task}, timeout=fallback_deadline_sec) + if not done: + _abandoned_fallbacks.add(task) + task.add_done_callback(_reap_abandoned) + LOG.w(f"[fallback:{src}] 데드라인 {fallback_deadline_sec:.0f}s 초과 → 스킵(크롤은 백그라운드 종료)") + return [] + try: + crawled = task.result() + except Exception as ex: + LOG.w(f"[fallback:{src}] 크롤 실패(무시): {type(ex).__name__}: {ex}") + return [] + hits = await _match(target, crawled, base_price, metrics) + if hits: + LOG.d(f"[fallback:{src}] 크롤 {len(crawled)}건 중 같은상품 {len(hits)}건 병합") + return hits + + results = await asyncio.gather(*[_crawl_match(s, a) for s, a in todo]) + for hits in results: + matched = matched + hits + return matched + + async def _round_queries(base_query: str, target: dict, metrics: SearchMetrics): + """라운드 쿼리 지연 생성: 원본 → (0매칭 시에만 LLM 호출로) 정밀 → 광역.""" + yield ("original", base_query) + if keyword_gen is not None: + kw = await keyword_gen.generate(target) # 원본이 실패해 여기까지 온 경우에만 호출됨 + metrics.add_ai(keyword_gen.last_usage) + seen = {base_query} + for label, q in (("precise", kw.precise), ("broad", kw.broad)): + q = (q or "").strip() + if q and q not in seen: + seen.add(q) + yield (label, q) + + async def handler(job: dict) -> dict: + if job["job_type"] != JobType.SEARCH.value: + raise ValueError(f"unsupported job_type: {job['job_type']}") + + payload = job.get("payload") or {} + base_query = (payload.get("product_name") or "").strip() + if not base_query: + raise ValueError("empty product_name") + target = {k: payload.get(k, "") for k in ("product_name", "model", "specification", "company")} + base_price = parse_price(payload.get("price")) + cache_key = payload.get("product_code") or base_query + metrics = SearchMetrics(ai_model, proxy_cost_per_gb) # 검색 1건의 리소스/비용/시간 계측 + + # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 + if neg_cache is not None and await neg_cache.is_negative(cache_key): + return {"outcome": "not_found", "cached": True, "query": base_query, + "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}, + "metrics": metrics.snapshot()} + + rounds_done = 0 + last_stages, last_sources = [], {} + async for label, query in _round_queries(base_query, target, metrics): + if rounds_done >= max_rounds: + break + rounds_done += 1 + + products, per_source, tech_failed = await _search_round(query, metrics) + candidates, stages = apply_filters(products, base_price=base_price) + if judge is not None and candidates: + verdicts = await judge.judge(target, candidates) + metrics.add_ai(judge.last_usage) + matched = [c for c, v in zip(candidates, verdicts) if v.is_match] + stages.append({"stage": "ai_match", "in": len(candidates), "out": len(matched)}) + candidates = matched + last_stages, last_sources = stages, per_source + + if candidates: # 찾음 → 오픈마켓 폴백 보강 후 종료 + before = len(candidates) + candidates = await _enrich_with_fallback(target, query, candidates, base_price, metrics) + if len(candidates) > before: + stages.append({"stage": "fallback_crawl", "in": before, "out": len(candidates)}) + result = rank_result(candidates, len(products), stages, top_n) + result.update(outcome="found", query=query, round=label, rounds_tried=rounds_done, + sources=per_source, metrics=metrics.snapshot()) + await _record_history(cache_key, job.get("job_id"), "found", candidates) + return result + + if tech_failed: # 0매칭인데 소스가 죽어 있었음 → '없음'이라 단정 불가 → 기술 재시도 + raise RuntimeError(f"기술적 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") + + # 모든 라운드 클린 0매칭 → 정상 not_found 종료 + if neg_cache is not None: + await neg_cache.put(cache_key, reason=f"not_found after {rounds_done} rounds") + result = rank_result([], 0, last_stages, top_n) + result.update(outcome="not_found", query=base_query, rounds_tried=rounds_done, + sources=last_sources, metrics=metrics.snapshot()) + await _record_history(cache_key, job.get("job_id"), "not_found", []) + return result + + return handler diff --git a/lps/worker/notify.py b/lps/worker/notify.py new file mode 100644 index 0000000..b90320d --- /dev/null +++ b/lps/worker/notify.py @@ -0,0 +1,47 @@ +"""LISTEN/NOTIFY 리스너 — 잡 적재 시 워커를 즉시 깨운다(폴링 낭비 제거). + +전용 asyncpg 연결로 LISTEN 한다(SQLAlchemy 풀과 분리). 알림이 오면 이벤트를 세팅하고, +워커는 claim 이 비었을 때 wait()로 알림 또는 짧은 타임아웃(안전망/reaper)까지 대기한다. +""" + +import asyncio + +import asyncpg + +from config.server_configs import main_db_config +from crud.job_crud import JOB_NOTIFY_CHANNEL + + +def _dsn() -> str: + c = main_db_config + pw = f":{c.write_pw}" if c.write_pw else "" + return f"postgresql://{c.write_id}{pw}@{c.write_host}:{c.write_port}/{c.name}" + + +class JobListener: + def __init__(self, channel: str = JOB_NOTIFY_CHANNEL): + self._channel = channel + self._conn: asyncpg.Connection | None = None + self._event = asyncio.Event() + + async def start(self): + self._conn = await asyncpg.connect(_dsn()) + await self._conn.add_listener(self._channel, self._on_notify) + + def _on_notify(self, *_args): + self._event.set() + + async def wait(self, timeout: float) -> bool: + """알림이 오거나 timeout 까지 대기. 알림으로 깨면 True, 타임아웃이면 False.""" + try: + await asyncio.wait_for(self._event.wait(), timeout) + return True + except asyncio.TimeoutError: + return False + finally: + self._event.clear() + + async def close(self): + if self._conn is not None: + await self._conn.close() + self._conn = None diff --git a/lps/worker/runner.py b/lps/worker/runner.py new file mode 100644 index 0000000..77f6b30 --- /dev/null +++ b/lps/worker/runner.py @@ -0,0 +1,99 @@ +"""워커 루프 + reaper. + +워커는 큐에서 잡을 원자적으로 claim → 핸들러 실행 → complete/fail 한다. +- 처리 중 heartbeat 로 lease 를 갱신(긴 잡이 reaper 에 회수되지 않게). +- 핸들러엔 데드라인(job_deadline_sec)을 건다 — heartbeat 가 lease 를 계속 갱신하므로 + 핸들러가 행하면 reaper 로는 영원히 회수 불가(2026-07-10 부하테스트에서 크롤 15분 행 실측). + 초과 시 취소 후 fail 처리 → 백오프 재큐(소진 시 DEAD), 워커 슬롯은 즉시 다음 잡으로. +- claim 이 비면 LISTEN 알림 또는 짧은 타임아웃까지 대기(폴링 최소화). +- 핸들러는 주입식(async def(job)->dict) — 프로덕션은 검색 파이프라인, 테스트는 fake. +""" + +import asyncio + +from common.enums import JobStatus +from common.logger import LOG +from crud.job_crud import JobQueue, compute_backoff + + +class Worker: + def __init__(self, worker_id: str, queue: JobQueue, handler, lease_sec: int = 120, backoff_fn=compute_backoff, + job_deadline_sec: float = 300.0): + self.worker_id = worker_id + self.queue = queue + self.handler = handler + self.lease_sec = lease_sec + self.backoff_fn = backoff_fn + self.job_deadline_sec = job_deadline_sec # 잡 1건 처리 시간 상한(0 이면 무제한 — 테스트용) + + async def process_one(self) -> bool: + """대기 잡 1건을 claim·처리. 처리했으면 True, 없으면 False.""" + job = await self.queue.claim(self.worker_id, self.lease_sec) + if not job: + return False + await self._process(job) + return True + + async def drain(self) -> int: + """큐가 빌 때까지 처리(테스트/일회성 배치용). 처리한 잡 수 반환.""" + n = 0 + while await self.process_one(): + n += 1 + return n + + async def run(self, listener=None, stop: asyncio.Event | None = None, idle_timeout: float = 5.0): + """상시 루프. stop 이 설정될 때까지 처리하고, 유휴 시 알림/타임아웃까지 대기.""" + stop = stop or asyncio.Event() + while not stop.is_set(): + worked = await self.process_one() + if not worked: + if listener is not None: + await listener.wait(idle_timeout) + else: + await asyncio.sleep(idle_timeout) + + async def _process(self, job: dict): + jid = job["job_id"] + hb = asyncio.create_task(self._heartbeat(jid)) + try: + if self.job_deadline_sec > 0: + result = await asyncio.wait_for(self.handler(job), timeout=self.job_deadline_sec) + else: + result = await self.handler(job) + await self.queue.complete(jid, self.worker_id, result) + LOG.d(f"[{self.worker_id}] done {jid}") + except TimeoutError: + # 데드라인 초과 — wait_for 가 핸들러 태스크를 취소한 뒤 여기로 온다. in-flight 크롤이 + # 취소되며 브라우저가 어중간한 상태로 남을 수 있지만, 어댑터가 다음 검색에서 재기동으로 + # 회복한다. 행이 워커 슬롯을 영구 점유하는 것보다 낫다. + backoff = self.backoff_fn(job["attempts"]) + st = await self.queue.fail(jid, self.worker_id, f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s", backoff) + LOG.w(f"[{self.worker_id}] deadline {jid} → {JobStatus(st).name if st else '?'} ({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)") + except Exception as ex: + backoff = self.backoff_fn(job["attempts"]) + st = await self.queue.fail(jid, self.worker_id, f"{type(ex).__name__}: {ex}", backoff) + LOG.w(f"[{self.worker_id}] fail {jid} → {JobStatus(st).name if st else '?'} ({type(ex).__name__}: {ex})") + finally: + hb.cancel() + try: + await hb + except asyncio.CancelledError: + pass + + async def _heartbeat(self, jid: str): + interval = max(1, self.lease_sec // 3) + while True: + await asyncio.sleep(interval) + await self.queue.renew_lease(jid, self.worker_id, self.lease_sec) + + +async def run_reaper(queue: JobQueue, stop: asyncio.Event, interval: float = 30.0): + """만료 lease(워커 사망) 잡을 주기적으로 회수. 재시도 남으면 재큐, 소진되면 DEAD.""" + while not stop.is_set(): + reclaimed = await queue.reap() + if reclaimed: + LOG.w(f"[reaper] reclaimed {len(reclaimed)} stale job(s)") + try: + await asyncio.wait_for(stop.wait(), interval) + except asyncio.TimeoutError: + pass diff --git a/lps/worker_main.py b/lps/worker_main.py new file mode 100644 index 0000000..c0195fc --- /dev/null +++ b/lps/worker_main.py @@ -0,0 +1,276 @@ +# LPS 워커 프로세스 진입점 (API 와 분리 실행 — 코드베이스 공유, 독립 스케일). +# python worker_main.py +# WORKER_CONCURRENCY=3 python worker_main.py # 상품 3개 동시 검색(권장 2~3, 로컬) +# +# 브라우저 어댑터는 컨텍스트당 직렬(lock)이라, 진짜 병렬을 위해 **워커마다 자기 브라우저 세트**를 준다: +# 프로필 분리(user_data_dir_w{i}) + 워커별 다른 프록시 포트(=다른 IP). 동시성 N → 최대 4×N Chrome. + +import asyncio +import os +import signal +import time + +import httpx + +from common.logger import LOG +from config.server_configs import web_server_config, openai_config, decodo_config +from crud.job_crud import JobQueue +from crud.negative_cache import NegativeCache +from crud.bot_detection import BotDetectionLog +from crud.price_history import PriceHistory +from services.search.proxy import DecodoProxy +from services.search.coupang.adapter import CoupangAdapter +from services.search.naver.adapter import NaverAdapter +from services.search.esm.adapter import EsmAdapter +from services.search.st11.adapter import ElevenStAdapter +from services.ai.similarity import SimilarityJudge +from services.ai.keyword import KeywordGenerator +from worker.handlers import build_search_handler +from worker.notify import JobListener +from worker.runner import Worker, run_reaper + +LOG.SetPrefix(f"{web_server_config.server_name}-worker") + +# 오픈마켓 폴백(G마켓·옥션·11번가)은 **기본 비활성** — 2026-07-10 협의 결정. +# 실측상 크롤 몰이 최종 최저가를 바꾼 적이 없고(0회), 검색당 최대 15s + 프록시 대역폭의 +# 대부분을 차지해 로직에서 제외했다(코드·테스트는 유지, 핸들러는 빈 폴백을 정상 처리). +# 재가동: LPS_FALLBACKS=gmarket,auction,st11 (일부만도 가능) — 켜기 전 라이브 스모크로 +# 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md. +_FALLBACK_SOURCES = ("gmarket", "auction", "st11") + + +def _enabled_fallbacks() -> list[str]: + names = [s.strip() for s in os.environ.get("LPS_FALLBACKS", "").split(",") if s.strip()] + unknown = [n for n in names if n not in _FALLBACK_SOURCES] + if unknown: + LOG.w(f"LPS_FALLBACKS 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})") + return [n for n in names if n in _FALLBACK_SOURCES] + + +def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history): + """워커 1개의 자립 세트(브라우저 어댑터·AI·핸들러)를 만든다. + 프로필 분리(user_data_dir_w{i}) + 워커별 다른 프록시 포트(=다른 IP)로 진짜 병렬을 보장한다.""" + # 워커별 프록시(다른 포트=다른 IP). 100포트를 워커 수로 균등 분할해 시작점을 벌린다. + proxy = DecodoProxy() + if proxy.enabled and concurrency > 1: + n = proxy.port_end - proxy.port_start + 1 + proxy.seed_offset(i * max(1, n // concurrency)) + bot_log = BotDetectionLog() + suffix = f"_w{i}" if concurrency > 1 else "" + + def _pf(source): # 워커별 Chrome 프로필 경로(중복 실행 시 ProcessSingleton 충돌 방지) + # LPS_PROFILE_DIR 를 영속 볼륨으로 마운트하면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피). + base = os.environ.get("LPS_PROFILE_DIR", "/tmp") + return f"{base}/lps_{source}{suffix}" + + adapters = { + "coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, on_detect=bot_log.record), + "naver": NaverAdapter(), # httpx 직접(프록시 미경유) — 워커별 인스턴스(last_bytes 경합 회피) + } + # 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라 + # 봇감지 재시도(챌린지 대기 2배)를 끈다(max_block_retries=0) — 빠르게 포기·스킵. + fallback_adapters = {} + for name in _enabled_fallbacks(): + if name == "st11": + fallback_adapters[name] = ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0) + else: + fallback_adapters[name] = EsmAdapter(name, headless=False, user_data_dir=_pf(name), proxy=proxy, on_detect=bot_log.record, max_block_retries=0) + # AI 도 워커별 인스턴스 — 공유 상태(last_usage) 경합 원천 제거 + judge = SimilarityJudge() if has_openai else None + keyword_gen = KeywordGenerator() if has_openai else None + handler = build_search_handler( + adapters, judge=judge, keyword_gen=keyword_gen, + neg_cache=neg_cache, history=history, + fallback_adapters=fallback_adapters, + ai_model=openai_config.model, + proxy_cost_per_gb=decodo_config.cost_per_gb, + ) + return handler, list(adapters.values()) + list(fallback_adapters.values()) + + +async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float = 60.0): + """워커의 챌린지 소스(Turnstile/Akamai)를 미리 풀어 쿠키(cf_clearance 등)를 확보한다. + 콜드 비용을 시작 시 몰아, 이후 실 작업은 웜(빠름). 백그라운드로 돌려 잡 처리를 막지 않는다. + 나쁜 IP 는 인터랙티브 Turnstile 로 에스컬레이션되므로, 실패 시 **다른 IP 로 회전 재시도**한다. + 시도당 타임아웃 필수 — 웜업은 search 중 어댑터 락을 쥐므로, 여기서 행하면 그 워커의 + 모든 실 검색이 락 대기로 함께 멈춘다(2026-07-10 부하테스트에서 15분 행 실측).""" + for ad in worker_adapters: + if ad.source not in ("gmarket", "auction", "coupang"): + continue + for attempt in range(tries): + try: + await asyncio.wait_for(ad.search("생수", limit=1), timeout=attempt_timeout) + LOG.i(f"[warmup:{ad.source}] 챌린지 통과·쿠키 확보 (시도 {attempt + 1})") + break + except Exception as ex: + if attempt < tries - 1: + ad._rotate_ip(f"웜업 재시도({type(ex).__name__}) — 새 IP") + else: + LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}") + + +async def _post_webhook(url: str, text: str, snap: dict): + """Slack 호환 웹훅으로 알림 전송(있을 때만). 실패는 무시.""" + try: + async with httpx.AsyncClient(timeout=5) as c: + await c.post(url, json={"text": f":rotating_light: LPS {text}\n```{snap}```"}) + except Exception: + pass + + +async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0): + """워커 헬스 하트비트 + 임계 알림. 주기적으로 (1) 하트비트 파일 갱신(Docker HEALTHCHECK 가 + 행/좀비 워커 감지) (2) 큐/차단 지표 점검 → 임계 초과 시 WARN 로그 + (env 있으면) 웹훅 알림.""" + hb_path = os.environ.get("LPS_HEARTBEAT_FILE", "/tmp/lps_worker_heartbeat") + webhook = os.environ.get("LPS_ALERT_WEBHOOK") + th_dead = int(os.environ.get("LPS_ALERT_DEAD_1H", "20")) + th_blocks = int(os.environ.get("LPS_ALERT_BLOCKS_1H", "80")) + th_lag = int(os.environ.get("LPS_ALERT_QUEUE_LAG_SEC", "300")) + while not stop.is_set(): + try: + with open(hb_path, "w") as f: + f.write(str(int(time.time()))) # 하트비트(mtime) — HEALTHCHECK 가 신선도 확인 + except Exception: + pass + try: + snap = await queue.ops() + snap["blocks_1h"] = await bot_log.recent_count(60) + alerts = [] + if snap["dead_1h"] >= th_dead: alerts.append(f"DEAD 1h={snap['dead_1h']}") + if snap["blocks_1h"] >= th_blocks: alerts.append(f"차단 1h={snap['blocks_1h']}") + if snap["oldest_pending_sec"] >= th_lag: alerts.append(f"큐지연={snap['oldest_pending_sec']}s") + if snap["stuck_running"] > 0: alerts.append(f"stuck={snap['stuck_running']}") + if alerts: + msg = "[ops-alert] " + " · ".join(alerts) + LOG.w(msg) + if webhook: + await _post_webhook(webhook, msg, snap) + except Exception as ex: + LOG.e_no_callstack(f"[ops-monitor] {type(ex).__name__}: {ex}") + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + except asyncio.TimeoutError: + pass + + +async def run_browser_reaper(adapters, stop, idle_sec: float = 120.0, interval: float = 30.0, + close_timeout: float = 60.0): + """유휴 브라우저 정리 루프 — 일정 시간 검색 없는 어댑터의 Chrome 을 닫아 메모리를 회수한다. + 쿠키는 user_data_dir 에 남아, 다음 검색 때 재기동해도 (같은 IP면) 웜 유지. + 순차 순회라 close 1건에도 타임아웃을 건다 — 한 어댑터의 close 행이 루프 전체를 멈춰 + 다른 워커의 브라우저까지 못 닫게 되는 것을 실측(2026-07-10 부하테스트)했다.""" + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + except asyncio.TimeoutError: + pass + for ad in adapters: + close_if_idle = getattr(ad, "close_if_idle", None) + if close_if_idle is None: # 네이버(httpx) 등 브라우저 없는 어댑터는 정리 대상 아님 + continue + try: + await asyncio.wait_for(close_if_idle(idle_sec), timeout=close_timeout) + except asyncio.TimeoutError: + LOG.w(f"[browser-reaper] {getattr(ad, 'source', '?')} 정리 {close_timeout:.0f}s 초과 — 취소·스킵(다음 틱 재시도)") + except Exception as ex: + LOG.e_no_callstack(f"[browser-reaper] 정리 실패(무시): {ex}") + + +async def main(concurrency: int = 1): + queue = JobQueue() + neg_cache, history = NegativeCache(), PriceHistory() # DB 기반 — 워커 공유 안전 + has_openai = bool(openai_config.api_key) + + # 시작 프리플라이트: DECODO 게이트가 살아있는지(인증) 대표 프록시로 1회 확인. 포트는 워커별로 각자 잡음. + probe = DecodoProxy() + LOG.i(f"DECODO 프록시: {'ON(sticky ' + str(probe.session_minutes) + '분 회전)' if probe.enabled else 'OFF(미설정)'}") + if probe.enabled: + egress_ip, egress_port = await probe.healthcheck() + LOG.i(f"DECODO 프리플라이트 OK — egress IP {egress_ip} (port {egress_port})") if egress_ip \ + else LOG.w("DECODO 프리플라이트 실패 — 살아있는 포트를 못 찾음(런타임 회전으로 재시도)") + fb = _enabled_fallbacks() + LOG.i(f"AI(판정+검색어생성): {'ON' if has_openai else 'OFF(키 없음)'} · " + f"오픈마켓 폴백: {', '.join(fb) if fb else 'OFF(기본 — LPS_FALLBACKS 로 활성화)'}") + + stop = asyncio.Event() + listeners: list[JobListener] = [] + tasks: list[asyncio.Task] = [] + bg_tasks: list[asyncio.Task] = [] # 웜업 등 백그라운드(짧게 끝남, gather 대상 아님) + all_adapters = [] + + # ── graceful shutdown: SIGINT(Ctrl+C)/SIGTERM(docker stop) → stop 이벤트 ── + # asyncio.run 기본 동작(SIGINT=메인 태스크 즉시 cancel)은 하던 잡을 도중에 끊어 + # RUNNING 인 채 lease 만료(120s)까지 묶어둔다. 대신 stop 을 set 해 "새 잡은 안 받고, + # 하던 잡은 마무리"로 종료한다. 같은 신호를 한 번 더 받으면 강제 종료(태스크 취소). + def _request_stop(sig_name: str): + if not stop.is_set(): + LOG.i(f"{sig_name} 수신 — graceful 종료: 새 잡 중단, 하던 잡 마무리 (한 번 더 = 강제 종료)") + stop.set() + for t in bg_tasks: # 웜업은 선택 작업 — 즉시 취소해 어댑터 락을 비운다 + t.cancel() + else: + LOG.w(f"{sig_name} 재수신 — 강제 종료(실행 중 잡은 lease 만료 후 reaper 가 재큐)") + for t in tasks: + t.cancel() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, _request_stop, sig.name) + + # 잡 1건 데드라인 — 정상 검색은 폴백 포함 수분 내 끝난다(실측 15~22s). 크롤 행 실측(15분) 대비 상한. + job_deadline = float(os.environ.get("LPS_JOB_DEADLINE_SEC", "300")) + for i in range(concurrency): + handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history) + all_adapters += worker_adapters + bg_tasks.append(asyncio.create_task(_warmup_worker(worker_adapters))) # 챌린지 쿠키 선점(백그라운드) + listener = JobListener() + await listener.start() + listeners.append(listener) + worker = Worker(f"worker-{i}", queue, handler, job_deadline_sec=job_deadline) + tasks.append(asyncio.create_task(worker.run(listener, stop))) + + tasks.append(asyncio.create_task(run_reaper(queue, stop))) + tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리 + tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop))) # 하트비트 + 임계 알림 + LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리 + ops모니터(하트비트/알림) 기동 (워커별 세트 · 상품 {concurrency}개 동시)") + + # 종료 유예: stop 후 하던 잡이 이 시간 안에 끝나면 자연 종료, 초과하면 강제 취소. + # docker stop 을 쓰면 compose 의 stop_grace_period 를 이보다 길게 잡아야 SIGKILL 전에 마무리된다. + grace = float(os.environ.get("LPS_SHUTDOWN_GRACE_SEC", "60")) + gathered = asyncio.gather(*tasks) + stop_waiter = asyncio.create_task(stop.wait()) + try: + await asyncio.wait({gathered, stop_waiter}, return_when=asyncio.FIRST_COMPLETED) + if gathered.done(): + gathered.result() # 워커/리퍼가 예외로 죽은 경우 → 전파(finally 가 정리 후 종료) + else: + # 종료 신호 경로 — 워커 루프들이 stop 을 보고 하던 잡을 마친 뒤 스스로 끝나길 기다린다 + try: + await asyncio.wait_for(gathered, timeout=grace) + LOG.i("graceful 종료 — 모든 워커가 하던 잡을 마무리함") + except asyncio.TimeoutError: + LOG.w(f"종료 유예 {grace:.0f}s 초과 — 남은 태스크 강제 취소(잡은 lease 만료 후 재큐)") + except asyncio.CancelledError: # 신호 재수신(강제 종료)로 태스크가 취소된 경우 + LOG.w("강제 종료 — 남은 리소스 정리 후 종료") + finally: + stop.set() + stop_waiter.cancel() + for t in (*tasks, *bg_tasks): + t.cancel() + # 취소 완주를 기다린 뒤 정리 — 실행 중 태스크가 브라우저/커넥션을 쓰는 채로 닫지 않게 + await asyncio.gather(gathered, stop_waiter, *bg_tasks, return_exceptions=True) + for listener in listeners: + try: + await listener.close() + except Exception as ex: + LOG.e_no_callstack(f"[shutdown] 리스너 정리 실패(무시): {ex}") + for adapter in all_adapters: # 항목별 격리 — 하나가 실패해도 나머지 Chrome 은 닫는다 + try: + await adapter.close() + except Exception as ex: + LOG.e_no_callstack(f"[shutdown] {getattr(adapter, 'source', '?')} 정리 실패(무시): {ex}") + LOG.i("LPS 워커 종료 완료") + + +if __name__ == "__main__": + asyncio.run(main(int(os.environ.get("WORKER_CONCURRENCY", "1"))))