Merge branch 'feature/lps' — 인터넷 최저가 검색(LPS) 시스템
This commit is contained in:
commit
18f6466a8b
21
.env.example
Normal file
21
.env.example
Normal file
@ -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)
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -24,3 +24,7 @@ CLAUDE.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
|
||||
/Temp.md
|
||||
/new.md
|
||||
|
||||
@ -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:
|
||||
|
||||
672
lps-temp-fe/index.html
Normal file
672
lps-temp-fe/index.html
Normal file
@ -0,0 +1,672 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LPS · 최저가 검색 콘솔 (임시 FE)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f6f8; --surface:#ffffff; --surface-2:#fafbfc;
|
||||
--border:#e6e8ec; --border-2:#eef0f3;
|
||||
--text-900:#1a1d24; --text-700:#3d434f; --text-500:#6b7280; --text-400:#9aa1ac;
|
||||
--primary-600:#4f46e5; --primary-500:#635bff; --primary-50:#eef0ff;
|
||||
--naver:#03c75a; --coupang:#f0455b; --final:#4f46e5;
|
||||
--ok-600:#0f9d58; --ok-50:#e7f6ee;
|
||||
--warn-600:#c77800; --warn-50:#fdf3e3;
|
||||
--run-600:#2563eb; --run-50:#e8f0ff;
|
||||
--dead-600:#dc2626; --dead-50:#fdeaea;
|
||||
--radius:14px; --radius-sm:9px;
|
||||
--shadow:0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06);
|
||||
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{background:var(--bg);color:var(--text-900);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary-600);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
|
||||
/* ---- layout ---- */
|
||||
header.top{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.82);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--border)}
|
||||
.top-inner{max-width:1180px;margin:0 auto;padding:12px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16px}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--primary-500),#8b7bff);display:grid;place-items:center;color:#fff;font-size:15px;font-weight:800}
|
||||
.brand small{display:block;font-weight:500;font-size:11px;color:var(--text-400);letter-spacing:.02em}
|
||||
.top-spacer{flex:1}
|
||||
.stat-badges{display:flex;gap:7px;flex-wrap:wrap}
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700)}
|
||||
.badge .dot{width:7px;height:7px;border-radius:50%;background:var(--text-400)}
|
||||
.badge b{font-variant-numeric:tabular-nums}
|
||||
.badge.pending .dot{background:var(--warn-600)} .badge.running .dot{background:var(--run-600)}
|
||||
.badge.done .dot{background:var(--ok-600)} .badge.dead .dot{background:var(--dead-600)}
|
||||
.health{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--text-500);font-weight:600}
|
||||
.pulse{width:8px;height:8px;border-radius:50%;background:var(--dead-600)}
|
||||
.pulse.up{background:var(--ok-600);box-shadow:0 0 0 0 rgba(15,157,88,.5);animation:pulse 2s infinite}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(15,157,88,.45)}70%{box-shadow:0 0 0 7px rgba(15,157,88,0)}100%{box-shadow:0 0 0 0 rgba(15,157,88,0)}}
|
||||
|
||||
main{max-width:1180px;margin:0 auto;padding:22px;display:grid;grid-template-columns:400px 1fr;gap:20px;align-items:start}
|
||||
@media (max-width:940px){main{grid-template-columns:1fr}}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.card + .card{margin-top:20px}
|
||||
.card-head{padding:15px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px}
|
||||
.card-head h2{margin:0;font-size:14px;font-weight:700;letter-spacing:-.01em}
|
||||
.card-head .hint{margin-left:auto;font-size:11.5px;color:var(--text-400);font-weight:500}
|
||||
.card-head .idx{width:22px;height:22px;border-radius:7px;background:var(--primary-50);color:var(--primary-600);display:grid;place-items:center;font-size:12px;font-weight:800}
|
||||
.card-body{padding:18px}
|
||||
|
||||
/* ---- form ---- */
|
||||
.field{margin-bottom:13px}
|
||||
.field:last-child{margin-bottom:0}
|
||||
.field label{display:block;font-size:12px;font-weight:600;color:var(--text-700);margin-bottom:5px}
|
||||
.field label .req{color:var(--coupang);margin-left:2px}
|
||||
.field .sub{font-weight:500;color:var(--text-400);font-size:11px}
|
||||
input,select,textarea{width:100%;padding:9px 11px;border:1px solid var(--border);border-radius:var(--radius-sm);font-size:13px;font-family:inherit;color:var(--text-900);background:var(--surface);transition:border-color .12s,box-shadow .12s}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary-500);box-shadow:0 0 0 3px var(--primary-50)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:11px}
|
||||
.btn{appearance:none;border:1px solid transparent;border-radius:var(--radius-sm);font-family:inherit;font-size:13px;font-weight:600;padding:10px 15px;cursor:pointer;transition:background .12s,border-color .12s,color .12s;display:inline-flex;align-items:center;justify-content:center;gap:7px}
|
||||
.btn-primary{background:var(--primary-600);color:#fff;width:100%}
|
||||
.btn-primary:hover{background:var(--primary-500)}
|
||||
.btn-primary:disabled{background:#c3c6cf;cursor:not-allowed}
|
||||
.btn-ghost{background:var(--surface);border-color:var(--border);color:var(--text-700)}
|
||||
.btn-ghost:hover{background:var(--surface-2)}
|
||||
.btn-sm{padding:7px 11px;font-size:12px}
|
||||
|
||||
.examples{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
|
||||
.chip{font-size:11.5px;padding:4px 9px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-700);cursor:pointer}
|
||||
.chip:hover{border-color:var(--primary-500);color:var(--primary-600);background:var(--primary-50)}
|
||||
|
||||
/* ---- job tracking ---- */
|
||||
.empty{padding:38px 20px;text-align:center;color:var(--text-400)}
|
||||
.empty .big{font-size:30px;margin-bottom:8px;opacity:.5}
|
||||
.job-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.job-id{font-family:var(--mono);font-size:12px;background:var(--surface-2);border:1px solid var(--border-2);padding:4px 8px;border-radius:6px;color:var(--text-700)}
|
||||
.status-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:700}
|
||||
.status-pill.PENDING{background:var(--warn-50);color:var(--warn-600)}
|
||||
.status-pill.RUNNING{background:var(--run-50);color:var(--run-600)}
|
||||
.status-pill.DONE{background:var(--ok-50);color:var(--ok-600)}
|
||||
.status-pill.DEAD{background:var(--dead-50);color:var(--dead-600)}
|
||||
.status-pill .spin{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.outcome-hero{display:flex;align-items:flex-end;gap:14px;padding:16px;border-radius:var(--radius-sm);background:linear-gradient(135deg,#f7f8ff,#f0f3ff);border:1px solid var(--border-2);margin-bottom:16px}
|
||||
.outcome-hero.nf{background:linear-gradient(135deg,#fbfbfc,#f4f5f7)}
|
||||
.price-big{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-variant-numeric:tabular-nums}
|
||||
.price-big .won{font-size:18px;font-weight:700;color:var(--text-500);margin-left:2px}
|
||||
.src-tag{font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px;color:#fff;letter-spacing:.02em;white-space:nowrap}
|
||||
.src-tag.naver{background:var(--naver)} .src-tag.coupang{background:var(--coupang)}
|
||||
.src-tag.gmarket{background:#00b25a} .src-tag.auction{background:#e60012} .src-tag.st11{background:#ff0038}
|
||||
.src-tag.fallback{position:relative}
|
||||
.src-tag.fallback::after{content:"크롤";position:absolute;top:-6px;right:-6px;font-size:8px;background:#1a1d24;color:#fff;padding:1px 3px;border-radius:4px;letter-spacing:0}
|
||||
.ship-tag{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:5px;border:1px solid transparent;white-space:nowrap}
|
||||
.ship-tag.rocket{background:#eef4ff;color:#2f6fed;border-color:#d5e3ff}
|
||||
.ship-tag.rocket_merchant{background:#f3eeff;color:#7a4fe0;border-color:#e3d8ff}
|
||||
.ship-tag.free{background:var(--ok-50);color:var(--ok-600)}
|
||||
.ship-tag.paid{background:#fbecec;color:var(--dead-600)}
|
||||
.ship-tag.unknown{background:var(--surface-2);color:var(--text-400);border-color:var(--border-2)}
|
||||
.outcome-label{font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}
|
||||
|
||||
/* funnel / stages */
|
||||
.stages{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.stage{flex:1;min-width:82px;background:var(--surface-2);border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px;text-align:center;position:relative}
|
||||
.stage .st-name{font-size:11px;color:var(--text-500);font-weight:600;margin-bottom:4px;text-transform:capitalize}
|
||||
.stage .st-io{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.stage .st-io .arr{color:var(--text-400);font-weight:500;margin:0 3px}
|
||||
.stage .st-drop{font-size:10.5px;color:var(--coupang);font-weight:600;margin-top:2px}
|
||||
|
||||
.src-counts{display:flex;gap:8px;margin-bottom:16px}
|
||||
.src-count{flex:1;border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 12px;background:var(--surface-2)}
|
||||
.src-count .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px}
|
||||
.src-count .lbl.naver{color:var(--naver)} .src-count .lbl.coupang{color:var(--coupang)}
|
||||
.src-count .n{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums}
|
||||
.src-count .err{font-size:11px;color:var(--dead-600);font-weight:600}
|
||||
|
||||
/* 몰별 최저가 분해 */
|
||||
.mall-bars{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
|
||||
.mall-bar{display:flex;align-items:center;gap:10px}
|
||||
.mall-bar .mname{width:112px;flex-shrink:0;font-size:12px;font-weight:600;color:var(--text-700);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:5px}
|
||||
.mall-bar .mname .sdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.mall-bar .track{flex:1;height:22px;background:var(--surface-2);border-radius:6px;position:relative;overflow:hidden}
|
||||
.mall-bar .fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;opacity:.16}
|
||||
.mall-bar .mprice{position:absolute;right:8px;top:0;bottom:0;display:flex;align-items:center;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text-900)}
|
||||
.mall-bar.best .mprice{color:var(--ok-600)}
|
||||
.mall-bar .badge-best{position:absolute;left:8px;top:0;bottom:0;display:flex;align-items:center;font-size:10px;font-weight:800;color:var(--ok-600)}
|
||||
|
||||
.sec-title{font-size:12px;font-weight:700;color:var(--text-700);margin:0 0 9px;display:flex;align-items:center;gap:7px}
|
||||
.sec-title .cnt{font-size:11px;color:var(--text-400);font-weight:600}
|
||||
|
||||
/* 검색 원가 계측 */
|
||||
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
|
||||
@media (max-width:520px){.metrics{grid-template-columns:repeat(2,1fr)}}
|
||||
.metric{border:1px solid var(--border-2);border-radius:var(--radius-sm);padding:10px 11px;background:var(--surface-2)}
|
||||
.metric .mk{font-size:10.5px;font-weight:700;color:var(--text-400);text-transform:uppercase;letter-spacing:.03em;margin-bottom:4px}
|
||||
.metric .mv{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.metric .mv small{font-size:11px;font-weight:600;color:var(--text-500)}
|
||||
.metric .msub{font-size:10.5px;color:var(--text-400);font-weight:600;margin-top:2px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;font-size:11px;font-weight:700;color:var(--text-500);text-transform:uppercase;letter-spacing:.03em;padding:8px 10px;border-bottom:1px solid var(--border-2)}
|
||||
td{padding:9px 10px;border-bottom:1px solid var(--border-2);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.price-cell{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.name-cell{max-width:340px}
|
||||
.name-cell a{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rank{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--surface-2);border:1px solid var(--border-2);font-size:11px;font-weight:800;color:var(--text-500)}
|
||||
.rank.top{background:var(--primary-50);color:var(--primary-600);border-color:transparent}
|
||||
|
||||
/* ---- chart ---- */
|
||||
.chart-controls{display:flex;gap:9px;align-items:flex-end;margin-bottom:16px}
|
||||
.chart-controls .field{flex:1;margin:0}
|
||||
.chart-wrap{position:relative;width:100%;overflow-x:auto}
|
||||
svg.chart{display:block;width:100%;height:auto;min-width:520px}
|
||||
.legend{display:flex;gap:16px;margin-top:12px;flex-wrap:wrap}
|
||||
.legend-item{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--text-700);cursor:pointer;user-select:none}
|
||||
.legend-item .swatch{width:14px;height:3px;border-radius:2px}
|
||||
.legend-item.off{opacity:.35}
|
||||
.legend-item .swatch.dashed{background:none!important;border-top:2px dashed}
|
||||
|
||||
.chart-tip{position:absolute;pointer-events:none;background:#1a1d24;color:#fff;padding:8px 10px;border-radius:8px;font-size:11.5px;box-shadow:0 6px 24px rgba(0,0,0,.2);opacity:0;transition:opacity .1s;z-index:5;min-width:130px;transform:translate(-50%,-115%)}
|
||||
.chart-tip .tt-time{color:#9aa1ac;font-size:10.5px;margin-bottom:5px}
|
||||
.chart-tip .tt-row{display:flex;justify-content:space-between;gap:12px;font-variant-numeric:tabular-nums}
|
||||
.chart-tip .tt-row .k{display:flex;align-items:center;gap:6px}
|
||||
.chart-tip .tt-dot{width:7px;height:7px;border-radius:50%}
|
||||
|
||||
/* ---- misc ---- */
|
||||
.toast-wrap{position:fixed;bottom:22px;right:22px;z-index:100;display:flex;flex-direction:column;gap:9px}
|
||||
.toast{background:#1a1d24;color:#fff;padding:12px 16px;border-radius:10px;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.24);display:flex;align-items:center;gap:9px;animation:slideIn .25s ease;max-width:340px}
|
||||
.toast.err{background:#b91c1c} .toast.ok{background:#0f9d58}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.muted{color:var(--text-400)}
|
||||
.spin-inline{width:13px;height:13px;border:2px solid var(--border);border-right-color:var(--primary-500);border-radius:50%;display:inline-block;animation:spin .7s linear infinite;vertical-align:-2px}
|
||||
.cfg-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-500)}
|
||||
.cfg-row input{width:auto;flex:1;padding:6px 9px;font-size:12px;font-family:var(--mono)}
|
||||
code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:4px;border:1px solid var(--border-2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<div class="brand">
|
||||
<div class="logo">₩</div>
|
||||
<div>LPS 최저가 검색 콘솔
|
||||
<small>임시 프론트엔드 · API 통신 확인용</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-spacer"></div>
|
||||
<div class="stat-badges" id="queueBadges">
|
||||
<span class="badge pending"><span class="dot"></span>PENDING <b>–</b></span>
|
||||
<span class="badge running"><span class="dot"></span>RUNNING <b>–</b></span>
|
||||
<span class="badge done"><span class="dot"></span>DONE <b>–</b></span>
|
||||
<span class="badge dead"><span class="dot"></span>DEAD <b>–</b></span>
|
||||
</div>
|
||||
<div class="health"><span class="pulse" id="healthDot"></span><span id="healthTxt">확인 중…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ LEFT: 검색 요청 ============ -->
|
||||
<section>
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">1</span><h2>검색 요청</h2><span class="hint">POST /v1/lps/search</span></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>빠른 예시</label>
|
||||
<div class="examples" id="examples"></div>
|
||||
</div>
|
||||
<form id="searchForm">
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>상품 코드 <span class="req">*</span> <span class="sub">이력 키</span></label>
|
||||
<input name="product_code" required placeholder="T1" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>우선순위 <span class="sub">job_type</span></label>
|
||||
<select name="job_type">
|
||||
<option value="new_product">new_product · 1 (최우선)</option>
|
||||
<option value="manual" selected>manual · 2 (기본)</option>
|
||||
<option value="partner">partner · 3</option>
|
||||
<option value="batch">batch · 4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>상품명 <span class="req">*</span></label>
|
||||
<input name="product_name" required placeholder="맥심 모카골드 커피믹스" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>규격 <span class="sub">자유 서술 — AI가 해석</span></label>
|
||||
<input name="specification" placeholder="1박스, 160개입" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>모델명 <span class="sub">선택</span></label>
|
||||
<input name="model" placeholder="모카골드" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>제조사 <span class="sub">선택</span></label>
|
||||
<input name="company" placeholder="동서식품" autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>현재가 <span class="sub">있으면 가격밴드 필터 기준</span></label>
|
||||
<input name="price" inputmode="numeric" placeholder="25000" autocomplete="off"/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">검색 요청 보내기</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>API 설정</h2><span class="hint">Base URL</span></div>
|
||||
<div class="card-body">
|
||||
<div class="cfg-row">
|
||||
<input id="baseUrl" value="http://localhost:9600"/>
|
||||
<button class="btn btn-ghost btn-sm" id="pingBtn">확인</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:10px 0 0;font-size:11.5px">
|
||||
이 페이지는 <code>:5173</code>에서 서빙되어야 CORS가 통과합니다 (<code>serve.sh</code> 참고).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RIGHT: 결과 + 그래프 ============ -->
|
||||
<section>
|
||||
<!-- 작업 추적 / 상품 정보 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">2</span><h2>작업 추적 & 상품 정보</h2><span class="hint" id="jobHint">GET /v1/lps/jobs/{id}</span></div>
|
||||
<div class="card-body" id="jobBody">
|
||||
<div class="empty">
|
||||
<div class="big">📦</div>
|
||||
왼쪽에서 검색을 요청하면 여기에서<br/>진행 상태와 최저가 결과가 실시간으로 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최저가 이력 그래프 -->
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="idx">3</span><h2>최저가 이력 그래프</h2><span class="hint">GET /v1/lps/products/{code}/history</span></div>
|
||||
<div class="card-body">
|
||||
<div class="chart-controls">
|
||||
<div class="field">
|
||||
<label>상품 코드</label>
|
||||
<input id="histCode" placeholder="P1" autocomplete="off"/>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" id="loadHistBtn" style="height:37px">이력 불러오기</button>
|
||||
</div>
|
||||
<div id="chartArea">
|
||||
<div class="empty"><div class="big">📈</div>상품 코드를 입력하고 이력을 불러오세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast-wrap" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (s,el=document)=>el.querySelector(s);
|
||||
const $$ = (s,el=document)=>Array.from(el.querySelectorAll(s));
|
||||
const base = ()=> $("#baseUrl").value.replace(/\/+$/,"");
|
||||
const won = n => (n==null? "—" : Number(n).toLocaleString("ko-KR"));
|
||||
const esc = s => (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||||
|
||||
/* 배송 뱃지: shipping_type + shipping_fee → 라벨. 네이버는 항상 '배송비 별도'. */
|
||||
function shipBadge(p){
|
||||
const t=p.shipping_type, fee=p.shipping_fee;
|
||||
if(p.source==="naver") return `<span class="ship-tag unknown" title="네이버 오픈API는 배송비 제외 상품가입니다">배송비 별도</span>`;
|
||||
const map={
|
||||
rocket:["rocket","🚀 로켓"], rocket_merchant:["rocket_merchant","🚀 판매자로켓"],
|
||||
free:["free","무료배송"], paid:["paid", fee!=null?`배송비 ${won(fee)}원`:"유료배송"],
|
||||
};
|
||||
if(t && map[t]) return `<span class="ship-tag ${map[t][0]}">${map[t][1]}</span>`;
|
||||
return `<span class="ship-tag unknown">배송비 미확인</span>`;
|
||||
}
|
||||
/* 실구매가(상품가+확정 배송비). 미확인이면 null. */
|
||||
function totalPrice(p){ return (typeof p.shipping_fee==="number") ? p.price+p.shipping_fee : null; }
|
||||
|
||||
/* 소스 메타: 표시명·색상. gmarket/auction/st11 은 네이버 폴백 크롤 소스. */
|
||||
const SRC = {
|
||||
naver: {label:"NAVER", color:"#03c75a", crawl:false},
|
||||
coupang:{label:"COUPANG",color:"#f0455b", crawl:false},
|
||||
gmarket:{label:"G마켓", color:"#00b25a", crawl:true},
|
||||
auction:{label:"옥션", color:"#e60012", crawl:true},
|
||||
st11: {label:"11번가", color:"#ff0038", crawl:true},
|
||||
};
|
||||
function srcColor(s){ return (SRC[s]||{}).color || "#6b7280"; }
|
||||
function srcTag(p){
|
||||
const m=SRC[p.source]||{label:p.source};
|
||||
return `<span class="src-tag ${p.source}${m.crawl?' fallback':''}" title="${m.crawl?'네이버 미커버 → 실사이트 크롤':''}">${esc(m.label)}</span>`;
|
||||
}
|
||||
/* 판매몰 표시명: 네이버 가격비교(mall='네이버')는 '여러 판매자 최저가 롤업'임을 명시. */
|
||||
function mallLabel(p){
|
||||
if(p.source==="coupang") return "쿠팡";
|
||||
const m=p.mall_name||(SRC[p.source]||{}).label||"기타";
|
||||
return (m==="네이버") ? "네이버 가격비교" : m;
|
||||
}
|
||||
function isCatalog(p){ return p.source==="naver" && (p.mall_name==="네이버"); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
function toast(msg,type=""){
|
||||
const t=document.createElement("div"); t.className="toast "+type; t.textContent=msg;
|
||||
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300)},3200);
|
||||
}
|
||||
|
||||
/* ---------- fetch helper ---------- */
|
||||
async function api(path,opts){
|
||||
const r = await fetch(base()+path, Object.assign({headers:{"Content-Type":"application/json"}},opts));
|
||||
const txt = await r.text();
|
||||
let data; try{ data = txt? JSON.parse(txt):{} }catch(e){ throw new Error("응답 파싱 실패: "+txt.slice(0,120)) }
|
||||
if(!r.ok) throw new Error("HTTP "+r.status+" · "+(data?.result?.desc||txt.slice(0,120)));
|
||||
if(data?.result && data.result.success===false) throw new Error(data.result.desc||"API 실패");
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------- health + queue polling ---------- */
|
||||
async function refreshHealth(){
|
||||
try{
|
||||
const t = await api("/healthz");
|
||||
$("#healthDot").className="pulse up";
|
||||
$("#healthTxt").textContent = "온라인 · "+(typeof t==="string"?t.replace("T"," ").slice(0,19):"");
|
||||
}catch(e){
|
||||
$("#healthDot").className="pulse";
|
||||
$("#healthTxt").textContent="오프라인";
|
||||
}
|
||||
}
|
||||
async function refreshQueue(){
|
||||
try{
|
||||
const d = await api("/v1/lps/queue/stats");
|
||||
const c = d.counts||{};
|
||||
const b = $$("#queueBadges .badge b");
|
||||
b[0].textContent=c.PENDING??0; b[1].textContent=c.RUNNING??0; b[2].textContent=c.DONE??0; b[3].textContent=c.DEAD??0;
|
||||
}catch(e){}
|
||||
}
|
||||
function poll(){ refreshHealth(); refreshQueue(); }
|
||||
poll(); setInterval(poll, 4000);
|
||||
$("#pingBtn").addEventListener("click",()=>{ refreshHealth(); refreshQueue(); toast("연결 확인 중…"); });
|
||||
|
||||
/* ---------- examples ---------- */
|
||||
const EXAMPLES = [
|
||||
{label:"맥심 커피", product_code:"T1", product_name:"맥심 모카골드 커피믹스", specification:"1박스, 160개입", company:"동서식품", model:"모카골드", price:"25000"},
|
||||
{label:"신라면", product_code:"T2", product_name:"농심 신라면", specification:"1박스 40개입", company:"농심", price:"22000"},
|
||||
{label:"P1 (샘플이력)", product_code:"P1", product_name:"테스트 상품 P1", specification:"", price:""},
|
||||
];
|
||||
const exWrap = $("#examples");
|
||||
EXAMPLES.forEach(ex=>{
|
||||
const c=document.createElement("button"); c.type="button"; c.className="chip"; c.textContent=ex.label;
|
||||
c.addEventListener("click",()=>{ for(const k in ex){ if(k==="label")continue; const f=$(`[name=${k}]`); if(f) f.value=ex[k]; } toast("예시 채움: "+ex.label); });
|
||||
exWrap.appendChild(c);
|
||||
});
|
||||
|
||||
/* ---------- submit search ---------- */
|
||||
let pollTimer=null;
|
||||
$("#searchForm").addEventListener("submit", async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target); const item={};
|
||||
fd.forEach((v,k)=> item[k]=String(v).trim());
|
||||
if(!item.product_code||!item.product_name){ toast("상품 코드와 상품명은 필수입니다","err"); return; }
|
||||
const btn=$("#submitBtn"); btn.disabled=true; btn.innerHTML='<span class="spin-inline"></span> 요청 중…';
|
||||
try{
|
||||
const d = await api("/v1/lps/search",{method:"POST",body:JSON.stringify({data:[item]})});
|
||||
const it = (d.items||[])[0]||{};
|
||||
if(it.duplicated){ toast("이미 처리 중인 활성 작업이라 중복 접수 생략됨","err"); }
|
||||
else if(it.job_id){ toast("접수 완료 · 추적 시작","ok"); $("#histCode").value=item.product_code; trackJob(it.job_id, item); }
|
||||
else { toast("접수됐지만 job_id가 없습니다","err"); }
|
||||
}catch(err){ toast(err.message,"err"); }
|
||||
finally{ btn.disabled=false; btn.textContent="검색 요청 보내기"; }
|
||||
});
|
||||
|
||||
/* ---------- job tracking (poll until DONE/DEAD) ---------- */
|
||||
function trackJob(jobId, submitted){
|
||||
if(pollTimer) clearInterval(pollTimer);
|
||||
$("#jobHint").textContent = "job "+jobId.slice(0,8)+"…";
|
||||
let tries=0;
|
||||
const tick = async ()=>{
|
||||
tries++;
|
||||
try{
|
||||
const d = await api("/v1/lps/jobs/"+jobId);
|
||||
renderJob(d, submitted);
|
||||
if(d.status==="DONE"||d.status==="DEAD"){
|
||||
clearInterval(pollTimer); pollTimer=null;
|
||||
if(d.status==="DONE" && d.output?.outcome==="found") loadHistory(submitted.product_code);
|
||||
}
|
||||
}catch(err){
|
||||
renderJobError(err.message);
|
||||
if(tries>3){ clearInterval(pollTimer); pollTimer=null; }
|
||||
}
|
||||
};
|
||||
tick(); pollTimer=setInterval(tick, 1500);
|
||||
}
|
||||
function renderJobError(msg){ $("#jobBody").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(msg)}</div>`; }
|
||||
|
||||
function renderJob(d, submitted){
|
||||
const st=d.status||"PENDING";
|
||||
const o=d.output||null;
|
||||
const spinning=(st==="PENDING"||st==="RUNNING");
|
||||
let html = `<div class="job-meta">
|
||||
<span class="status-pill ${st}">${spinning?'<span class="spin"></span>':''}${st}</span>
|
||||
<span class="job-id">${esc(d.job_id||"")}</span>
|
||||
<span class="muted" style="font-size:12px">시도 ${d.attempts??0}/${d.max_attempts??"–"}</span>
|
||||
</div>`;
|
||||
|
||||
if(!o && spinning){
|
||||
html += `<div class="empty" style="padding:26px"><span class="spin-inline"></span> 워커가 네이버·쿠팡을 검색하고 AI가 같은 상품을 판정하는 중…
|
||||
<div class="muted" style="margin-top:8px;font-size:11.5px">쿠팡 크롤링은 수 초~수십 초 걸릴 수 있습니다.</div></div>`;
|
||||
$("#jobBody").innerHTML=html; return;
|
||||
}
|
||||
if(d.last_error && st==="DEAD"){
|
||||
html += `<div class="outcome-hero nf"><div><div class="outcome-label">작업 실패 (DEAD)</div><div class="muted" style="font-size:13px">${esc(d.last_error)}</div></div></div>`;
|
||||
}
|
||||
if(o){
|
||||
const found = o.outcome==="found";
|
||||
const low = o.lowest;
|
||||
// hero
|
||||
html += `<div class="outcome-hero ${found?'':'nf'}">
|
||||
<div style="flex:1">
|
||||
<div class="outcome-label">${found?'최저가 발견':(o.cached?'네거티브 캐시 (최근 없음)':'같은 상품 없음 · not_found')}</div>
|
||||
${ found && low ? `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<div class="price-big">${won(low.price)}<span class="won">원</span></div>
|
||||
${srcTag(low)}
|
||||
${shipBadge(low)}
|
||||
</div>
|
||||
<div class="muted" style="font-size:11.5px;margin-top:5px">판매몰 <b style="color:var(--text-700)">${esc(mallLabel(low))}</b>${ isCatalog(low)?` <span title="여러 판매자 중 최저가 롤업 · 배송비 제외">· 여러 판매자 최저가</span>`:``}</div>
|
||||
${ totalPrice(low)!=null && low.shipping_fee>0 ? `<div class="muted" style="font-size:11.5px;margin-top:2px">실구매가(배송비 포함) <b style="color:var(--text-700)">${won(totalPrice(low))}원</b></div>`:``}
|
||||
<div class="muted" style="font-size:12px;margin-top:6px">${esc(low.name||"")}</div>`
|
||||
: `<div class="price-big muted" style="font-size:22px">—</div>` }
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="muted" style="font-size:11px">검색어</div>
|
||||
<div style="font-weight:600;font-size:13px">${esc(o.query||submitted?.product_name||"")}</div>
|
||||
<div class="muted" style="font-size:11px;margin-top:4px">${o.rounds_tried??0} 라운드${o.round?' · '+esc(o.round):''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// source counts
|
||||
if(o.sources){
|
||||
html += `<div class="src-counts">`;
|
||||
for(const src of ["naver","coupang"]){
|
||||
const s=o.sources[src];
|
||||
html += `<div class="src-count"><div class="lbl ${src}">${src}</div>`;
|
||||
if(s && s.error!==undefined) html+=`<div class="err">차단/오류</div>`;
|
||||
else html += `<div class="n">${s?.count??0}<span class="muted" style="font-size:12px;font-weight:600"> 건</span></div>`;
|
||||
html+=`</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// pipeline stages
|
||||
if(o.stages && o.stages.length){
|
||||
html += `<div class="sec-title">파이프라인 단계 <span class="cnt">몇 건이 걸러졌나</span></div><div class="stages">`;
|
||||
o.stages.forEach(s=>{
|
||||
const drop=(s.in??0)-(s.out??0);
|
||||
html += `<div class="stage"><div class="st-name">${esc(s.stage)}</div>
|
||||
<div class="st-io">${s.in??0}<span class="arr">→</span>${s.out??0}</div>
|
||||
${drop>0?`<div class="st-drop">−${drop}</div>`:``}</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// 검색 원가 계측(리소스/비용/시간)
|
||||
const mt=o.metrics;
|
||||
if(mt){
|
||||
const fmtBytes=b=>{const kb=(b||0)/1024; return kb>=1024?(kb/1024).toFixed(1)+" MB":Math.round(kb)+" KB";};
|
||||
const c=mt.cost||{}; const total=c.total_usd??(mt.ai?.est_cost_usd||0);
|
||||
const fc=v=>"$"+(v||0).toFixed((v||0)<0.01?6:4);
|
||||
const srcMs=mt.source_ms||{};
|
||||
const srcTxt=Object.entries(srcMs).map(([s,ms])=>`${(SRC[s]||{}).label||s} ${(ms/1000).toFixed(1)}s`).join(" · ");
|
||||
const crawledTxt=(mt.crawl?.malls_crawled||[]).length?' · 크롤 '+mt.crawl.malls_crawled.map(s=>(SRC[s]||{}).label||s).join(','):'';
|
||||
html += `<div class="sec-title">검색 원가 <span class="cnt">이 검색 1건이 쓴 리소스·비용·시간</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="mk">소요 시간</div><div class="mv">${(mt.duration_ms/1000).toFixed(1)}<small>s</small></div>${srcTxt?`<div class="msub" title="${esc(srcTxt)}">${esc(srcTxt.length>34?srcTxt.slice(0,34)+'…':srcTxt)}</div>`:``}</div>
|
||||
<div class="metric"><div class="mk">총 비용</div><div class="mv">${fc(total)}</div><div class="msub" title="AI(OpenAI) + DECODO(프록시 대역폭)">AI ${fc(c.ai_usd)} · DECODO ${fc(c.proxy_usd)}</div></div>
|
||||
<div class="metric"><div class="mk">AI 토큰</div><div class="mv">${won((mt.ai?.prompt_tokens||0)+(mt.ai?.completion_tokens||0))}</div><div class="msub">${mt.ai?.calls||0}회 · in ${won(mt.ai?.prompt_tokens||0)}/out ${won(mt.ai?.completion_tokens||0)}</div></div>
|
||||
<div class="metric"><div class="mk">크롤 트래픽</div><div class="mv">${fmtBytes(mt.crawl?.html_bytes)}</div><div class="msub" title="프록시 경유 ${fmtBytes(mt.crawl?.proxy_bytes)} (DECODO 과금분)">${mt.crawl?.fetches||0} fetch · 프록시 ${fmtBytes(mt.crawl?.proxy_bytes)}${crawledTxt}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 몰별 최저가 분해 (네이버 payload에 이미 들어온 G마켓/옥션/11번가 등을 추가요청 0으로 노출)
|
||||
const byMall=o.by_mall||[];
|
||||
if(byMall.length>1){
|
||||
const maxP=Math.max(...byMall.map(m=>m.price));
|
||||
html += `<div class="sec-title">몰별 최저가 <span class="cnt">네이버 포함 몰 + 미커버 몰은 직접 크롤(크롤 뱃지)</span></div><div class="mall-bars">`;
|
||||
byMall.forEach((m,i)=>{
|
||||
const w=Math.max(8,Math.round(m.price/maxP*100));
|
||||
const color=srcColor(m.source);
|
||||
html += `<div class="mall-bar ${i===0?'best':''}">
|
||||
<div class="mname"><span class="sdot" style="background:${color}"></span>${esc(mallLabel(m))}</div>
|
||||
<div class="track"><div class="fill" style="width:${w}%;background:${color}"></div>
|
||||
${i===0?`<div class="badge-best">최저</div>`:``}
|
||||
<div class="mprice">${won(m.price)}원</div></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// top-N product list
|
||||
const top=o.top||[];
|
||||
if(top.length){
|
||||
html += `<div class="sec-title">같은 상품 최저가 <span class="cnt">상위 ${top.length}개 · 가격은 상품가 기준</span></div>
|
||||
<table><thead><tr><th>#</th><th>소스 · 판매몰</th><th>상품명</th><th>배송</th><th style="text-align:right">상품가</th></tr></thead><tbody>`;
|
||||
top.forEach((p,i)=>{
|
||||
const tot=totalPrice(p);
|
||||
html += `<tr>
|
||||
<td><span class="rank ${i===0?'top':''}">${i+1}</span></td>
|
||||
<td style="white-space:nowrap">${srcTag(p)}<div class="muted" style="font-size:11px;font-weight:600;margin-top:3px">${esc(mallLabel(p))}</div></td>
|
||||
<td class="name-cell">${p.detail_url?`<a href="${esc(p.detail_url)}" target="_blank" rel="noopener">${esc(p.name)}</a>`:esc(p.name)}</td>
|
||||
<td>${shipBadge(p)}</td>
|
||||
<td class="price-cell" style="text-align:right">${won(p.price)}원${ tot!=null&&p.shipping_fee>0?`<div class="muted" style="font-size:10.5px;font-weight:600">+배송 ${won(tot)}</div>`:``}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
}
|
||||
$("#jobBody").innerHTML=html;
|
||||
}
|
||||
|
||||
/* ---------- price history chart (dependency-free SVG) ---------- */
|
||||
const SERIES=[
|
||||
{key:"naver", label:"네이버", color:"var(--naver)", raw:"#03c75a", dash:false},
|
||||
{key:"coupang", label:"쿠팡", color:"var(--coupang)", raw:"#f0455b", dash:false},
|
||||
{key:"final", label:"최종최저", color:"var(--final)", raw:"#4f46e5", dash:true},
|
||||
];
|
||||
const hidden=new Set();
|
||||
let lastPoints=[];
|
||||
|
||||
$("#loadHistBtn").addEventListener("click",()=> loadHistory($("#histCode").value.trim()));
|
||||
$("#histCode").addEventListener("keydown",e=>{ if(e.key==="Enter") loadHistory(e.target.value.trim()); });
|
||||
|
||||
async function loadHistory(code){
|
||||
if(!code){ toast("상품 코드를 입력하세요","err"); return; }
|
||||
$("#histCode").value=code;
|
||||
$("#chartArea").innerHTML=`<div class="empty" style="padding:30px"><span class="spin-inline"></span> 이력 불러오는 중…</div>`;
|
||||
try{
|
||||
const d = await api(`/v1/lps/products/${encodeURIComponent(code)}/history?limit=200`);
|
||||
lastPoints = d.points||[];
|
||||
renderChart(lastPoints, code);
|
||||
}catch(err){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">⚠️</div>${esc(err.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderChart(points, code){
|
||||
if(!points.length){ $("#chartArea").innerHTML=`<div class="empty"><div class="big">🗒️</div><b>${esc(code)}</b> 상품의 이력이 아직 없습니다.<div class="muted" style="margin-top:6px;font-size:12px">검색이 완료되면 스냅샷이 쌓입니다.</div></div>`; return; }
|
||||
const W=760,H=300,padL=62,padR=18,padT=18,padB=46;
|
||||
const iw=W-padL-padR, ih=H-padT-padB;
|
||||
const vals=[]; points.forEach(p=>SERIES.forEach(s=>{ if(!hidden.has(s.key)&&p[s.key]!=null) vals.push(p[s.key]); }));
|
||||
let min = vals.length?Math.min(...vals):0, max=vals.length?Math.max(...vals):1;
|
||||
if(min===max){ min=min*0.9; max=max*1.1||1; }
|
||||
const pad=(max-min)*0.12; min=Math.max(0,min-pad); max=max+pad;
|
||||
const n=points.length;
|
||||
const X = i => padL + (n===1? iw/2 : iw*i/(n-1));
|
||||
const Y = v => padT + ih*(1-(v-min)/(max-min));
|
||||
|
||||
// y ticks
|
||||
const ticks=5; let yAxis="";
|
||||
for(let t=0;t<=ticks;t++){ const v=min+(max-min)*t/ticks; const y=Y(v);
|
||||
yAxis+=`<line x1="${padL}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="var(--border-2)" stroke-width="1"/>`;
|
||||
yAxis+=`<text x="${padL-8}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-400)">${won(Math.round(v))}</text>`;
|
||||
}
|
||||
// x labels (sparse)
|
||||
let xAxis=""; const step=Math.ceil(n/6);
|
||||
points.forEach((p,i)=>{ if(i%step!==0 && i!==n-1) return; const x=X(i);
|
||||
const t=(p.triggered_at||"").replace("T"," ").slice(5,16);
|
||||
xAxis+=`<text x="${x}" y="${H-padB+18}" text-anchor="middle" font-size="9.5" fill="var(--text-400)">${esc(t)}</text>`;
|
||||
xAxis+=`<line x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--border-2)" stroke-width="1" stroke-dasharray="2 3" opacity=".5"/>`;
|
||||
});
|
||||
// lines + dots
|
||||
let paths="",dots="";
|
||||
SERIES.forEach(s=>{
|
||||
if(hidden.has(s.key)) return;
|
||||
// build segments skipping nulls
|
||||
let seg=[];
|
||||
const flush=()=>{ if(seg.length>1){ paths+=`<polyline points="${seg.map(pt=>pt.x+","+pt.y).join(" ")}" fill="none" stroke="${s.raw}" stroke-width="${s.dash?2.5:2}" ${s.dash?'stroke-dasharray="6 4"':''} stroke-linejoin="round" stroke-linecap="round"/>`; } seg=[]; };
|
||||
points.forEach((p,i)=>{ const v=p[s.key]; if(v==null){ flush(); return; } const x=X(i),y=Y(v); seg.push({x,y});
|
||||
dots+=`<circle cx="${x}" cy="${y}" r="3" fill="#fff" stroke="${s.raw}" stroke-width="2"/>`; });
|
||||
flush();
|
||||
});
|
||||
// hover columns
|
||||
let hover="";
|
||||
points.forEach((p,i)=>{ const x=X(i);
|
||||
hover+=`<rect x="${x-(iw/n/2||14)}" y="${padT}" width="${iw/n||28}" height="${ih}" fill="transparent" data-i="${i}" class="hovcol"/>`;
|
||||
hover+=`<line class="hovline" data-i="${i}" x1="${x}" y1="${padT}" x2="${x}" y2="${padT+ih}" stroke="var(--primary-500)" stroke-width="1" opacity="0"/>`;
|
||||
});
|
||||
|
||||
$("#chartArea").innerHTML=`
|
||||
<div class="chart-wrap">
|
||||
<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
||||
${yAxis}${xAxis}${paths}${dots}${hover}
|
||||
</svg>
|
||||
<div class="chart-tip" id="chartTip"></div>
|
||||
</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="muted" style="font-size:11.5px;margin:12px 0 0">스냅샷 ${n}건 · 점선(최종최저)이 네이버·쿠팡 중 낮은 값을 따라갑니다. 값이 빈 구간은 그 소스에 상품이 없던 시점입니다.</p>
|
||||
`;
|
||||
// legend
|
||||
const lg=$("#legend");
|
||||
SERIES.forEach(s=>{
|
||||
const el=document.createElement("div"); el.className="legend-item"+(hidden.has(s.key)?" off":"");
|
||||
el.innerHTML=`<span class="swatch ${s.dash?'dashed':''}" style="${s.dash?`border-color:${s.raw}`:`background:${s.raw}`}"></span>${s.label}`;
|
||||
el.addEventListener("click",()=>{ if(hidden.has(s.key))hidden.delete(s.key); else hidden.add(s.key); renderChart(lastPoints,code); });
|
||||
lg.appendChild(el);
|
||||
});
|
||||
// hover interaction
|
||||
const tip=$("#chartTip"), svg=$(".chart");
|
||||
$$(".hovcol").forEach(col=>{
|
||||
col.style.cursor="crosshair";
|
||||
col.addEventListener("mouseenter",()=>{
|
||||
const i=+col.dataset.i, p=points[i];
|
||||
$$(".hovline").forEach(l=>l.setAttribute("opacity", (+l.dataset.i===i)?".5":"0"));
|
||||
let rows="";
|
||||
SERIES.forEach(s=>{ if(hidden.has(s.key)) return; const v=p[s.key];
|
||||
rows+=`<div class="tt-row"><span class="k"><span class="tt-dot" style="background:${s.raw}"></span>${s.label}</span><span>${v==null?'—':won(v)+'원'}</span></div>`; });
|
||||
tip.innerHTML=`<div class="tt-time">${esc((p.triggered_at||'').replace('T',' ').slice(0,19))} · ${esc(p.outcome||'')}</div>${rows}`;
|
||||
// position
|
||||
const bb=svg.getBoundingClientRect(); const scale=bb.width/W; const px=(X(i))*scale; const py=padT*scale+8;
|
||||
tip.style.left=px+"px"; tip.style.top=py+"px"; tip.style.opacity="1";
|
||||
});
|
||||
col.addEventListener("mouseleave",()=>{ tip.style.opacity="0"; $$(".hovline").forEach(l=>l.setAttribute("opacity","0")); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
27
lps-temp-fe/serve.sh
Executable file
27
lps-temp-fe/serve.sh
Executable file
@ -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
|
||||
15
lps/.dockerignore
Normal file
15
lps/.dockerignore
Normal file
@ -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
|
||||
5
lps/.gitignore
vendored
Normal file
5
lps/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Chrome 프로필(쿠키·cf_clearance 세션) — 로컬 워커 영속 볼륨. 절대 커밋하지 않는다.
|
||||
.profiles/
|
||||
|
||||
# 로컬 워커 하트비트/부하 테스트 로그(기본은 /tmp 지만 상대경로로 뜰 때 대비)
|
||||
lps_worker_heartbeat
|
||||
27
lps/Dockerfile
Normal file
27
lps/Dockerfile
Normal file
@ -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"]
|
||||
38
lps/Dockerfile.worker
Normal file
38
lps/Dockerfile.worker
Normal file
@ -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"]
|
||||
132
lps/README.md
Normal file
132
lps/README.md
Normal file
@ -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**.
|
||||
206
lps/common/database/db_session_manager.py
Normal file
206
lps/common/database/db_session_manager.py
Normal file
@ -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()
|
||||
127
lps/common/database/model/models.py
Normal file
127
lps/common/database/model/models.py
Normal file
@ -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()"))
|
||||
71
lps/common/enums.py
Normal file
71
lps/common/enums.py
Normal file
@ -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 결과 전송(재시도 엔진 공유) — 가벼움
|
||||
43
lps/common/logger.py
Normal file
43
lps/common/logger.py
Normal file
@ -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()
|
||||
44
lps/common/models/gmodel.py
Normal file
44
lps/common/models/gmodel.py
Normal file
@ -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="부가 메시지 (선택)")
|
||||
16
lps/common/singleton.py
Normal file
16
lps/common/singleton.py
Normal file
@ -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
|
||||
21
lps/common/utils/gtime.py
Normal file
21
lps/common/utils/gtime.py
Normal file
@ -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)
|
||||
74
lps/config/config.local.toml.example
Normal file
74
lps/config/config.local.toml.example
Normal file
@ -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 = "<DB_USER>"
|
||||
write_pw = "<DB_PASSWORD>"
|
||||
read_host = "127.0.0.1"
|
||||
read_port = 5432
|
||||
read_id = "<DB_USER>"
|
||||
read_pw = "<DB_PASSWORD>"
|
||||
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 = "<NAVER_CLIENT_ID>"
|
||||
secret = "<NAVER_CLIENT_SECRET>"
|
||||
# 추가 키는 아래처럼 블록을 더 넣으면 됨:
|
||||
# [[NaverConfig.keys]]
|
||||
# id = "..."
|
||||
# secret = "..."
|
||||
|
||||
# AI 유사도 판정/검색어 생성 (OpenAI)
|
||||
[OpenAIConfig]
|
||||
api_key = "<OPENAI_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)
|
||||
37
lps/config/config_loader.py
Normal file
37
lps/config/config_loader.py
Normal file
@ -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)
|
||||
78
lps/config/config_models.py
Normal file
78
lps/config/config_models.py
Normal file
@ -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) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정)
|
||||
114
lps/config/server_configs.py
Normal file
114
lps/config/server_configs.py
Normal file
@ -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)
|
||||
55
lps/conftest.py
Normal file
55
lps/conftest.py
Normal file
@ -0,0 +1,55 @@
|
||||
# 테스트도 APP_ENV=local 로 실행한다 (config.local.toml 사용).
|
||||
# config.server_configs 가 import 되는 순간 config.<APP_ENV>.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
|
||||
2
lps/crud/.gitkeep
Normal file
2
lps/crud/.gitkeep
Normal file
@ -0,0 +1,2 @@
|
||||
# crud 폴더 placeholder — DB 접근 계층(crud)을 여기에 추가한다.
|
||||
# backend/crud/*.py 컨벤션: ISomethingCRUD(ABC) + SomethingCRUD 구현, DB_SESSION_MNG.execute 등 저수준 연산 호출.
|
||||
37
lps/crud/bot_detection.py
Normal file
37
lps/crud/bot_detection.py
Normal file
@ -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)
|
||||
237
lps/crud/job_crud.py
Normal file
237
lps/crud/job_crud.py
Normal file
@ -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)
|
||||
47
lps/crud/negative_cache.py
Normal file
47
lps/crud/negative_cache.py
Normal file
@ -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)
|
||||
67
lps/crud/price_history.py
Normal file
67
lps/crud/price_history.py
Normal file
@ -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)
|
||||
158
lps/docs/api.md
Normal file
158
lps/docs/api.md
Normal file
@ -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`(검색했으나 같은 상품 없음)
|
||||
174
lps/docs/architecture.md
Normal file
174
lps/docs/architecture.md
Normal file
@ -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가 "같은 상품인지" 판단.
|
||||
- **오픈마켓은 네이버 폴백**: 네이버가 이미 커버하는 몰은 재크롤하지 않고, 못 덮은 몰만 크롤(비용 절약).
|
||||
|
||||
더 깊은 내부 구현은 각 파일 상단 주석에 정리되어 있습니다.
|
||||
108
lps/docs/database.md
Normal file
108
lps/docs/database.md
Normal file
@ -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)).
|
||||
68
lps/docs/decision-openmarket-crawler.md
Normal file
68
lps/docs/decision-openmarket-crawler.md
Normal file
@ -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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.
|
||||
196
lps/docs/operations.md
Normal file
196
lps/docs/operations.md
Normal file
@ -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 <id>` / `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 캐시**가 다음 절감 후보.
|
||||
124
lps/loadtest.py
Normal file
124
lps/loadtest.py
Normal file
@ -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())
|
||||
71
lps/loadtest/README.md
Normal file
71
lps/loadtest/README.md
Normal file
@ -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
|
||||
69
lps/loadtest/bench_multicore.sh
Executable file
69
lps/loadtest/bench_multicore.sh
Executable file
@ -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;' (쌓인 부하 잡 제거)"
|
||||
102
lps/loadtest/catalog.json
Normal file
102
lps/loadtest/catalog.json
Normal file
@ -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개"}
|
||||
]
|
||||
63
lps/loadtest/locustfile.py
Normal file
63
lps/loadtest/locustfile.py
Normal file
@ -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")
|
||||
354
lps/loadtest/monitor.py
Normal file
354
lps/loadtest/monitor.py
Normal file
@ -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"""<!doctype html>
|
||||
<html lang="ko"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LPS 모니터</title>
|
||||
<style>
|
||||
:root {
|
||||
--page:#0d0d0d; --surface:#1a1a19; --ink:#ffffff; --ink2:#c3c2b7; --muted:#898781;
|
||||
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,.10);
|
||||
/* 큐 시리즈 */ --q-pending:#3987e5; --q-running:#c98500; --q-done:#199e70; --q-dead:#e66767;
|
||||
/* 프로세스 그룹 */ --g-worker:#9085e9; --g-api:#008300; --g-chrome:#d95926; --g-postgres:#d55181;
|
||||
--core:#3987e5;
|
||||
}
|
||||
* { box-sizing:border-box; margin:0 }
|
||||
body { background:var(--page); color:var(--ink); font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif; padding:20px }
|
||||
h1 { font-size:16px; font-weight:600 }
|
||||
.sub { color:var(--muted); font-size:12px; margin:2px 0 16px }
|
||||
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:10px; margin-bottom:16px }
|
||||
.tile { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px 12px }
|
||||
.tile .k { color:var(--muted); font-size:11px }
|
||||
.tile .v { font-size:22px; font-weight:600; margin-top:2px }
|
||||
.tile .v small { font-size:12px; color:var(--ink2); font-weight:400 }
|
||||
.cards { display:grid; grid-template-columns:1fr 1fr; gap:14px }
|
||||
@media (max-width:1000px){ .cards { grid-template-columns:1fr } }
|
||||
.card { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px }
|
||||
.card h2 { font-size:13px; font-weight:600; color:var(--ink2); margin-bottom:2px }
|
||||
.card .desc { color:var(--muted); font-size:11px; margin-bottom:8px }
|
||||
.legend { display:flex; gap:14px; flex-wrap:wrap; font-size:12px; color:var(--ink2); margin-bottom:6px }
|
||||
.legend i { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px }
|
||||
.plot { position:relative }
|
||||
canvas { width:100%; height:220px; display:block }
|
||||
#cores-canvas { height:150px }
|
||||
.tip { position:absolute; pointer-events:none; background:#242423; border:1px solid var(--border);
|
||||
border-radius:8px; padding:8px 10px; font-size:12px; color:var(--ink2); display:none; z-index:5;
|
||||
white-space:nowrap; box-shadow:0 4px 14px rgba(0,0,0,.4) }
|
||||
.tip b { color:var(--ink); font-variant-numeric:tabular-nums }
|
||||
table { width:100%; border-collapse:collapse; font-size:12px; margin-top:4px }
|
||||
th { text-align:left; color:var(--muted); font-weight:500; padding:4px 8px; border-bottom:1px solid var(--axis) }
|
||||
td { padding:4px 8px; color:var(--ink2); border-bottom:1px solid var(--grid); font-variant-numeric:tabular-nums }
|
||||
td:first-child { color:var(--ink) }
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px }
|
||||
.ok { background:#0ca30c } .bad { background:#d03b3b }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>LPS 실시간 모니터</h1>
|
||||
<div class="sub"><span id="conn" class="dot bad"></span>대상 <span id="base">…</span> · 2초 샘플링 · 최근 1시간 유지</div>
|
||||
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<h2>큐 추이</h2><div class="desc">/v1/lps/ops — 잡 상태별 개수. DONE 이 계단처럼 오르면 정상 소화 중</div>
|
||||
<div class="legend" id="lg-queue"></div>
|
||||
<div class="plot"><canvas id="queue-canvas"></canvas><div class="tip" id="queue-tip"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>프로세스 그룹 CPU</h2><div class="desc">코어 1개=100% 기준(멀티코어면 100 초과). 어느 층이 바쁜지 = 병목 후보</div>
|
||||
<div class="legend" id="lg-cpu"></div>
|
||||
<div class="plot"><canvas id="cpu-canvas"></canvas><div class="tip" id="cpu-tip"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>코어별 사용률 (현재)</h2><div class="desc">막대가 고르게 차면 멀티코어 활용 중, 1~2개만 차면 단일 코어 병목</div>
|
||||
<div class="plot"><canvas id="cores-canvas"></canvas><div class="tip" id="cores-tip"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>현재 스냅샷 (표)</h2><div class="desc">그래프와 같은 데이터의 수치 뷰</div>
|
||||
<table id="snap-table"><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
|
||||
const QUEUE = [ ["PENDING","--q-pending"], ["RUNNING","--q-running"], ["DONE","--q-done"], ["DEAD","--q-dead"] ];
|
||||
const CPUG = [ ["worker","--g-worker"], ["api","--g-api"], ["chrome","--g-chrome"], ["postgres","--g-postgres"] ];
|
||||
let samples = [], last = 0, ncores = 0;
|
||||
|
||||
function legend(el, defs){ el.innerHTML = defs.map(([n,v]) => `<span><i style="background:${css(v)}"></i>${n}</span>`).join(""); }
|
||||
legend(document.getElementById("lg-queue"), QUEUE);
|
||||
legend(document.getElementById("lg-cpu"), CPUG);
|
||||
|
||||
function fit(cv){ // DPR 스케일
|
||||
const r = cv.getBoundingClientRect(), d = devicePixelRatio || 1;
|
||||
if (cv.width !== r.width*d) { cv.width = r.width*d; cv.height = r.height*d; }
|
||||
const ctx = cv.getContext("2d"); ctx.setTransform(d,0,0,d,0,0); return [ctx, r.width, r.height];
|
||||
}
|
||||
|
||||
// ── 시계열 라인 차트(직접 라벨 + 크로스헤어 툴팁) ──────────────────────────
|
||||
function lineChart(cvId, tipId, seriesDefs, getVal, fmt){
|
||||
const cv = document.getElementById(cvId), tip = document.getElementById(tipId);
|
||||
const PADL = 44, PADR = 78, PADT = 8, PADB = 20;
|
||||
let hoverX = null;
|
||||
function draw(){
|
||||
const [ctx,W,H] = fit(cv); ctx.clearRect(0,0,W,H);
|
||||
const pts = samples; if (pts.length < 2) return;
|
||||
const t0 = pts[0].t, t1 = pts[pts.length-1].t;
|
||||
let vmax = 1;
|
||||
for (const s of pts) for (const [name] of seriesDefs){ const v = getVal(s,name); if (v != null && v > vmax) vmax = v; }
|
||||
vmax *= 1.08;
|
||||
const X = t => PADL + (W-PADL-PADR) * (t-t0) / Math.max(1,(t1-t0));
|
||||
const Y = v => PADT + (H-PADT-PADB) * (1 - v/vmax);
|
||||
ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1; ctx.fillStyle = css("--muted"); ctx.font = "10px system-ui";
|
||||
let prevLabel = null;
|
||||
for (let i=0;i<=3;i++){ const v = vmax*i/3, y = Y(v), label = fmt(v, vmax);
|
||||
ctx.beginPath(); ctx.moveTo(PADL,y); ctx.lineTo(W-PADR,y); ctx.stroke();
|
||||
if (label !== prevLabel){ ctx.textAlign="right"; ctx.fillText(label, PADL-6, y+3); prevLabel = label; } }
|
||||
ctx.textAlign="center";
|
||||
for (let i=0;i<=3;i++){ const t = t0+(t1-t0)*i/3;
|
||||
ctx.fillText(new Date(t*1000).toTimeString().slice(0,8), X(t), H-6); }
|
||||
ctx.strokeStyle = css("--axis"); ctx.beginPath(); ctx.moveTo(PADL,Y(0)); ctx.lineTo(W-PADR,Y(0)); ctx.stroke();
|
||||
const ends = []; // 라인 끝 직접 라벨(색 단독 의존 방지) — 겹치면 아래로 밀어 12px 간격 확보
|
||||
for (const [name,varName] of seriesDefs){
|
||||
ctx.strokeStyle = css(varName); ctx.lineWidth = 2; ctx.beginPath(); let started=false, lastY=null;
|
||||
for (const s of pts){ const v = getVal(s,name); if (v==null) continue;
|
||||
const x=X(s.t), y=Y(v); started ? ctx.lineTo(x,y) : ctx.moveTo(x,y); started=true; lastY=y; }
|
||||
ctx.stroke();
|
||||
if (lastY != null) ends.push({ name, y: lastY });
|
||||
}
|
||||
ends.sort((a,b) => a.y - b.y);
|
||||
for (let i=1;i<ends.length;i++) ends[i].y = Math.max(ends[i].y, ends[i-1].y + 12);
|
||||
if (ends.length){ // 바닥을 넘치면 위로 되밀기(겹침 방지 유지)
|
||||
ends[ends.length-1].y = Math.min(ends[ends.length-1].y, H-PADB-2);
|
||||
for (let i=ends.length-2;i>=0;i--) ends[i].y = Math.min(ends[i].y, ends[i+1].y - 12);
|
||||
}
|
||||
ctx.fillStyle = css("--ink2"); ctx.textAlign="left"; ctx.font="11px system-ui";
|
||||
for (const e of ends) ctx.fillText(e.name, W-PADR+6, e.y+3);
|
||||
if (hoverX != null){
|
||||
let best=null, bd=1e9;
|
||||
for (const s of pts){ const d = Math.abs(X(s.t)-hoverX); if (d<bd){bd=d;best=s;} }
|
||||
if (best){ const x = X(best.t);
|
||||
ctx.strokeStyle = css("--axis"); ctx.setLineDash([3,3]); ctx.beginPath();
|
||||
ctx.moveTo(x,PADT); ctx.lineTo(x,H-PADB); ctx.stroke(); ctx.setLineDash([]);
|
||||
tip.style.display="block";
|
||||
tip.innerHTML = new Date(best.t*1000).toTimeString().slice(0,8) + "<br>" +
|
||||
seriesDefs.map(([n,v]) => `<i class="dot" style="background:${css(v)}"></i>${n} <b>${fmt(getVal(best,n) ?? 0)}</b>`).join("<br>");
|
||||
const r = cv.getBoundingClientRect();
|
||||
tip.style.left = Math.min(x+12, r.width-tip.offsetWidth-4) + "px"; tip.style.top = "10px";
|
||||
}
|
||||
} else tip.style.display="none";
|
||||
}
|
||||
cv.addEventListener("mousemove", e => { hoverX = e.offsetX; draw(); });
|
||||
cv.addEventListener("mouseleave", () => { hoverX = null; draw(); });
|
||||
return draw;
|
||||
}
|
||||
|
||||
const drawQueue = lineChart("queue-canvas","queue-tip", QUEUE,
|
||||
(s,n) => s.ops ? s.ops[n.toLowerCase()] : null, v => Math.round(v));
|
||||
const drawCpu = lineChart("cpu-canvas","cpu-tip", CPUG,
|
||||
(s,n) => s.groups?.[n]?.cpu, (v, vmax) => (vmax ?? 100) < 10 ? v.toFixed(1)+"%" : Math.round(v)+"%");
|
||||
|
||||
// ── 코어별 막대(단일 시리즈 → 범례 없음, 값 직접 라벨) ─────────────────────
|
||||
function drawCores(){
|
||||
const cv = document.getElementById("cores-canvas");
|
||||
const [ctx,W,H] = fit(cv); ctx.clearRect(0,0,W,H);
|
||||
const s = samples[samples.length-1]; if (!s) return;
|
||||
const cores = s.cores, n = cores.length, PADB = 18, PADT = 14;
|
||||
const bw = Math.min(46, (W-16)/n - 6);
|
||||
const X = i => 8 + i*( (W-16)/n ) + ((W-16)/n - bw)/2;
|
||||
ctx.strokeStyle = css("--axis"); ctx.beginPath(); ctx.moveTo(4,H-PADB); ctx.lineTo(W-4,H-PADB); ctx.stroke();
|
||||
cores.forEach((v,i) => {
|
||||
const h = Math.max(2,(H-PADT-PADB) * v/100), x = X(i), y = H-PADB-h;
|
||||
ctx.fillStyle = css("--core"); ctx.beginPath();
|
||||
ctx.roundRect(x, y, bw, h, [4,4,0,0]); ctx.fill();
|
||||
ctx.fillStyle = css("--muted"); ctx.font="10px system-ui"; ctx.textAlign="center";
|
||||
ctx.fillText("c"+i, x+bw/2, H-5);
|
||||
ctx.fillStyle = css("--ink2"); ctx.fillText(Math.round(v), x+bw/2, y-4);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 타일 + 표 ────────────────────────────────────────────────────────────────
|
||||
function tile(k,v,sub){ return `<div class="tile"><div class="k">${k}</div><div class="v">${v}${sub?` <small>${sub}</small>`:""}</div></div>`; }
|
||||
function throughput(){ // 최근 60초 ΔDONE → 상품/분
|
||||
const now = samples[samples.length-1], past = [...samples].reverse().find(s => s.ops && now.t - s.t >= 60);
|
||||
if (!now?.ops || !past?.ops) return "–";
|
||||
const d = now.ops.done - past.ops.done, dt = now.t - past.t;
|
||||
return dt > 0 ? (d*60/dt).toFixed(1) : "–";
|
||||
}
|
||||
function render(){
|
||||
const s = samples[samples.length-1]; if (!s) return;
|
||||
const o = s.ops, totalCpu = s.cores.reduce((a,b)=>a+b,0) / s.cores.length;
|
||||
document.getElementById("conn").className = "dot " + (o ? "ok" : "bad");
|
||||
document.getElementById("tiles").innerHTML =
|
||||
tile("DONE", o ? o.done : "–") + tile("처리량", throughput(), "개/분") +
|
||||
tile("RUNNING", o ? o.running : "–") + tile("PENDING", o ? o.pending : "–") +
|
||||
tile("큐 지연", o ? o.oldest_pending_sec : "–", "s") + tile("DEAD", o ? o.dead : "–") +
|
||||
tile("차단(1h)", o ? o.blocks_1h : "–") + tile("CPU 평균", totalCpu.toFixed(0), "%");
|
||||
const rows = [];
|
||||
for (const [g] of CPUG){ const d = s.groups[g];
|
||||
rows.push(`<tr><td><i class="dot" style="background:${css(CPUG.find(x=>x[0]===g)[1])}"></i>${g}</td><td>${d.cpu}%</td><td>${d.mem} MB</td><td>${d.n}개</td></tr>`); }
|
||||
if (o) rows.push(`<tr><td>ops</td><td colspan="3">stuck_running ${o.stuck_running} · dead_1h ${o.dead_1h} · blocks_1h ${o.blocks_1h}</td></tr>`);
|
||||
document.getElementById("snap-table").innerHTML =
|
||||
"<tr><th>그룹</th><th>CPU</th><th>MEM</th><th>프로세스</th></tr>" + rows.join("");
|
||||
drawQueue(); drawCpu(); drawCores();
|
||||
}
|
||||
|
||||
async function poll(){
|
||||
try {
|
||||
const r = await (await fetch(`/api/series?since=${last}`)).json();
|
||||
document.getElementById("base").textContent = r.base; ncores = r.ncores;
|
||||
if (r.samples.length){ samples.push(...r.samples); last = samples[samples.length-1].t; }
|
||||
const cut = (samples[samples.length-1]?.t ?? 0) - 3600;
|
||||
while (samples.length && samples[0].t < cut) samples.shift();
|
||||
render();
|
||||
} catch (e) { document.getElementById("conn").className = "dot bad"; }
|
||||
}
|
||||
poll(); setInterval(poll, 2000);
|
||||
addEventListener("resize", render);
|
||||
</script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
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")
|
||||
5
lps/migrations/2026-07-09-price_history-by_mall.sql
Normal file
5
lps/migrations/2026-07-09-price_history-by_mall.sql
Normal file
@ -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;
|
||||
9
lps/pytest.ini
Normal file
9
lps/pytest.ini
Normal file
@ -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
|
||||
11
lps/requirements-api.txt
Normal file
11
lps/requirements-api.txt
Normal file
@ -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→워커 헬스 프록시 등 대비 유지
|
||||
19
lps/requirements.txt
Normal file
19
lps/requirements.txt
Normal file
@ -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) 사용률 샘플링
|
||||
78
lps/router/router.py
Normal file
78
lps/router/router.py
Normal file
@ -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.<domain>.<file> 를 import 후 include.
|
||||
app.include_router(router.v1.lps.search.router)
|
||||
67
lps/router/v1/lps/protocol.py
Normal file
67
lps/router/v1/lps/protocol.py
Normal file
@ -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="시각 오름차순 스냅샷(그래프용)")
|
||||
61
lps/router/v1/lps/search.py
Normal file
61
lps/router/v1/lps/search.py
Normal file
@ -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))
|
||||
20
lps/router/v1/validator/dependencies.py
Normal file
20
lps/router/v1/validator/dependencies.py
Normal file
@ -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")))
|
||||
84
lps/run_loadtest_gui.sh
Executable file
84
lps/run_loadtest_gui.sh
Executable file
@ -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
|
||||
67
lps/run_local_server.sh
Executable file
67
lps/run_local_server.sh
Executable file
@ -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
|
||||
63
lps/run_local_worker.sh
Executable file
63
lps/run_local_worker.sh
Executable file
@ -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
|
||||
31
lps/run_monitor.sh
Executable file
31
lps/run_monitor.sh
Executable file
@ -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
|
||||
2
lps/services/.gitkeep
Normal file
2
lps/services/.gitkeep
Normal file
@ -0,0 +1,2 @@
|
||||
# services 폴더 placeholder — 도메인 비즈니스 로직(service)을 여기에 추가한다.
|
||||
# backend/services/*.py 컨벤션: 라우터가 Depends 로 주입받고, DB 접근은 crud + DB_SESSION_MNG 람다로 위임.
|
||||
51
lps/services/ai/keyword.py
Normal file
51
lps/services/ai/keyword.py
Normal file
@ -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
|
||||
71
lps/services/ai/similarity.py
Normal file
71
lps/services/ai/similarity.py
Normal file
@ -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
|
||||
98
lps/services/lps_service.py
Normal file
98
lps/services/lps_service.py
Normal file
@ -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
|
||||
90
lps/services/metrics.py
Normal file
90
lps/services/metrics.py
Normal file
@ -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,
|
||||
}
|
||||
103
lps/services/pipeline/core.py
Normal file
103
lps/services/pipeline/core.py
Normal file
@ -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)
|
||||
26
lps/services/pipeline/filters.py
Normal file
26
lps/services/pipeline/filters.py
Normal file
@ -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]
|
||||
32
lps/services/pipeline/outliers.py
Normal file
32
lps/services/pipeline/outliers.py
Normal file
@ -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
|
||||
290
lps/services/search/browser_base.py
Normal file
290
lps/services/search/browser_base.py
Normal file
@ -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
|
||||
74
lps/services/search/card_parser.py
Normal file
74
lps/services/search/card_parser.py
Normal file
@ -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
|
||||
61
lps/services/search/contract.py
Normal file
61
lps/services/search/contract.py
Normal file
@ -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)
|
||||
46
lps/services/search/coupang/adapter.py
Normal file
46
lps/services/search/coupang/adapter.py
Normal file
@ -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)
|
||||
102
lps/services/search/coupang/parser.py
Normal file
102
lps/services/search/coupang/parser.py
Normal file
@ -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
|
||||
25
lps/services/search/coupang/selectors.py
Normal file
25
lps/services/search/coupang/selectors.py
Normal file
@ -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()
|
||||
0
lps/services/search/esm/__init__.py
Normal file
0
lps/services/search/esm/__init__.py
Normal file
87
lps/services/search/esm/adapter.py
Normal file
87
lps/services/search/esm/adapter.py
Normal file
@ -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
|
||||
38
lps/services/search/esm/selectors.py
Normal file
38
lps/services/search/esm/selectors.py
Normal file
@ -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}
|
||||
100
lps/services/search/naver/adapter.py
Normal file
100
lps/services/search/naver/adapter.py
Normal file
@ -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
|
||||
52
lps/services/search/naver/transform.py
Normal file
52
lps/services/search/naver/transform.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""네이버 쇼핑 API 응답 items → NormalizedProduct (순수 함수).
|
||||
|
||||
네트워크 무관 — 저장된/모의 JSON 으로 결정론적 테스트 가능.
|
||||
title 의 <b> 강조 태그·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
|
||||
84
lps/services/search/proxy.py
Normal file
84
lps/services/search/proxy.py
Normal file
@ -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
|
||||
26
lps/services/search/rate_limiter.py
Normal file
26
lps/services/search/rate_limiter.py
Normal file
@ -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()
|
||||
0
lps/services/search/st11/__init__.py
Normal file
0
lps/services/search/st11/__init__.py
Normal file
31
lps/services/search/st11/adapter.py
Normal file
31
lps/services/search/st11/adapter.py
Normal file
@ -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)
|
||||
20
lps/services/search/st11/selectors.py
Normal file
20
lps/services/search/st11/selectors.py
Normal file
@ -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]",
|
||||
)
|
||||
57
lps/services/search/util.py
Normal file
57
lps/services/search/util.py
Normal file
@ -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
|
||||
3
lps/tests/fixtures/auction_search.html
vendored
Normal file
3
lps/tests/fixtures/auction_search.html
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<!doctype html><html><body><ul><div class="itemcard"><div class="section--component_title"><p class="text--title">먼저 둘러보세요</p><a href="https://ad.esmplus.com" target="_blank" title="먼저 둘러보세요 상품등록 페이지로 이동합니다." class="link--page" rel="noreferrer"><span class="text">상품등록</span></a><div class="section--advertisement "><button type="button" title="광고 안내 레이어 보기" class="button--notice_about_advertisement"><span class="text">광고</span></button><div class="layer--information"><p class="text--information_description">파워클릭 광고를 구매한 상품 중, 연관성과 입찰가를 고려하여 전시됩니다.</p><button type="button" class="button--close_layer"><span class="ir">광고<!-- --> 안내 레이어 닫기</span></button></div></div></div><div class="section--itemcard"><div class="section--itemcard_img"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F241652670" class="link--itemcard " target="_blank" rel="noreferrer"><div class="box__event-tag"></div><img src="//image.auction.co.kr/itemimage/50/09/ae/5009ae17d7.jpg?ver=1783573622" srcset="//image.auction.co.kr/itemimage/50/09/ae/5009ae17d3.jpg?ver=1783573622 2x, //image.auction.co.kr/itemimage/50/09/ae/5009ae17d7.jpg?ver=1783573622 1x" alt="미국 스탠리 스탠리텀블러473 빨대텀블러 473ml 민트 진공 퀜처 슬림 보틀 보온병 보냉병" loading="lazy" class="image--itemcard"></a></div><div class="section--itemcard_info"><div class="section--itemcard_info_major"><div class="box__itemcard-lmo"></div><div class="area--itemcard_title"><span class="text--itemcard_title ellipsis"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F241652670" class="link--itemcard" target="_blank" rel="noreferrer"><span class="box__brand"><span class="for-a11y">브랜드명 </span><span class="text__brand">스탠리</span></span><span class="for-a11y">상품명 </span><span class="text--title">미국 스탠리 스탠리텀블러473 빨대텀블러 473ml 민트 진공 퀜처 슬림 보틀 보온병 보냉병<!-- --> </span></a></span></div><div class="area--itemcard_price"><span class="box__price-coupon"><span class="box__price-sale"><span class="text__sale-persent">1%</span><span class="box__price-original"><span class="for-a11y">원가</span><span class="text text__value">44,990</span><span class="text text__unit">원</span></span></span><span class="box__price-seller"><span class="text__sale-persent">1%</span><span class="for-a11y">할인가</span><strong class="text__price-seller">44,540</strong><span class="text__unit">원</span></span></span></div></div><div class="box__item-reward"><img src="//pics.auction.co.kr/mobile/single/common/logo_membership.png" alt="꼭멤버" class="image"><span class="text__reward"><span style="font-size:12px;font-weight:normal;color:#01A900">최대 </span><span style="font-size:12px;font-weight:bold;color:#01A900">2,227원</span><span style="font-size:12px;font-weight:normal;color:#01A900"> 적립</span></span></div><div class="section--itemcard_info_add"><ul class="list--addinfo"><li class="item"><span class="text--addinfo" style="font-size:12px;font-weight:normal;color:#616161">배송비 3,000원</span></li></ul></div><div class="section--itemcard_info_score"><ul class="list--score"><li class="item awards"><span class="for-a11y">후기평점 5점</span><div class="seller_awards" title="후기평점 5점"><span class="awards_points" style="width:100%"></span><span class="bg_star"> </span></div></li><li class="item reviewcnt"><span class="text--reviewcnt">후기 <!-- -->1</span><span class="for-a11y">건</span></li><li class="item buycnt"><span class="text--buycnt">구매 <!-- -->7</span><span class="for-a11y">건</span></li></ul></div><div class="section--itemcard_info_shop"><a class="link--shop" href="http://stores.auction.co.kr/ssgmall" target="_blank" title="스토어로 이동" rel="noreferrer"><span class="for-a11y">판매자</span><span class="text">신세계몰</span></a><span class="for-a11y">판매자평가정보</span><ul class="list--store_info"><li class="item top_seller"><div></div><div class="section--advertisement "><button type="button" title="최우수판매자 안내 레이어 보기" class="button--notice_about_advertisement"><span class="text">최우수판매자</span></button><div class="layer--information"><p class="text--information_description">옥션의 판매인증 평가기준을 모두 달성한 최상위 판매자</p><button type="button" class="button--close_layer"><span class="ir">최우수판매자<!-- --> 안내 레이어 닫기</span></button></div></div></li></ul></div></div></div></div>
|
||||
<div class="itemcard"><div class="section--itemcard"><div class="section--itemcard_img"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F267097609" class="link--itemcard " target="_blank" rel="noreferrer"><div class="box__event-tag"></div><img src="//image.auction.co.kr/itemimage/53/5b/b2/535bb25c07.jpg?ver=1783573622" srcset="//image.auction.co.kr/itemimage/53/5b/b2/535bb25c03.jpg?ver=1783573622 2x, //image.auction.co.kr/itemimage/53/5b/b2/535bb25c07.jpg?ver=1783573622 1x" alt="울트라 라지 핸들 텀블러 1.1L 대용량 사이즈 스트로우 포함" loading="lazy" class="image--itemcard"></a></div><div class="section--itemcard_info"><div class="section--itemcard_info_major"><div class="box__itemcard-lmo"></div><div class="area--itemcard_title"><span class="text--itemcard_title ellipsis"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F267097609" class="link--itemcard" target="_blank" rel="noreferrer"><span class="for-a11y">상품명 </span><span class="text--title">울트라 라지 핸들 텀블러 1.1L 대용량 사이즈 스트로우 포함<!-- --> </span></a></span></div><div class="area--itemcard_price"><span class="price_seller"><span class="for-a11y">상품금액</span><strong class="text--price_seller">34,200</strong><span class="text--unit">원</span></span></div></div><div class="box__item-reward"><img src="//pics.auction.co.kr/mobile/single/common/logo_membership.png" alt="꼭멤버" class="image"><span class="text__reward"><span style="font-size:12px;font-weight:normal;color:#01A900">최대 </span><span style="font-size:12px;font-weight:bold;color:#01A900">1,710원</span><span style="font-size:12px;font-weight:normal;color:#01A900"> 적립</span></span></div><div class="section--itemcard_info_add"><ul class="list--addinfo"><li class="item"><span class="text--addinfo" style="font-size:12px;font-weight:normal;color:#616161">배송비 3,000원</span></li></ul></div><div class="section--itemcard_info_score"><ul class="list--score"></ul></div><div class="section--itemcard_info_shop"><a class="link--shop" href="http://stores.auction.co.kr/gyeryong" target="_blank" title="스토어로 이동" rel="noreferrer"><span class="for-a11y">판매자</span><span class="text">계룡몰</span></a><span class="for-a11y">판매자평가정보</span><ul class="list--store_info"><li class="item top_seller"><div></div><div class="section--advertisement "><button type="button" title="최우수판매자 안내 레이어 보기" class="button--notice_about_advertisement"><span class="text">최우수판매자</span></button><div class="layer--information"><p class="text--information_description">옥션의 판매인증 평가기준을 모두 달성한 최상위 판매자</p><button type="button" class="button--close_layer"><span class="ir">최우수판매자<!-- --> 안내 레이어 닫기</span></button></div></div></li></ul></div></div></div></div>
|
||||
<div class="itemcard"><div class="section--itemcard"><div class="section--itemcard_img"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F214020169" class="link--itemcard " target="_blank" rel="noreferrer"><div class="box__event-tag"><span class="text__tag-label" style="background-color:#E63740">구매 220+</span></div><img src="//image.auction.co.kr/itemimage/58/82/9b/58829b1007.jpg?ver=1783573622" srcset="//image.auction.co.kr/itemimage/58/82/9b/58829b1003.jpg?ver=1783573622 2x, //image.auction.co.kr/itemimage/58/82/9b/58829b1007.jpg?ver=1783573622 1x" alt="1+1 대형텀블러 1.18L 대용량 스텐텀블러 빨대포함 보온보냉병 사무실물컵" loading="lazy" class="image--itemcard"></a></div><div class="section--itemcard_info"><div class="section--itemcard_info_major"><div class="box__itemcard-lmo"></div><div class="area--itemcard_title"><span class="text--itemcard_title ellipsis"><a href="http://itempage3.auction.co.kr/DetailView.aspx?itemno=F214020169" class="link--itemcard" target="_blank" rel="noreferrer"><span class="for-a11y">상품명 </span><span class="text--title">1+1 대형텀블러 1.18L 대용량 스텐텀블러 빨대포함 보온보냉병 사무실물컵<!-- --> </span></a></span></div><div class="area--itemcard_price"><span class="box__price-coupon"><span class="box__price-sale"><span class="text__sale-persent">9%</span><span class="box__price-original"><span class="for-a11y">원가</span><span class="text text__value">20,900</span><span class="text text__unit">원</span></span></span><span class="box__price-seller"><span class="text__sale-persent">9%</span><span class="for-a11y">할인가</span><strong class="text__price-seller">19,000</strong><span class="text__unit">원</span></span></span></div></div><div class="box__item-reward"><img src="//pics.auction.co.kr/mobile/single/common/logo_membership.png" alt="꼭멤버" class="image"><span class="text__reward"><span style="font-size:12px;font-weight:normal;color:#01A900">최대 </span><span style="font-size:12px;font-weight:bold;color:#01A900">950원</span><span style="font-size:12px;font-weight:normal;color:#01A900"> 적립</span></span></div><div class="section--itemcard_info_add"><ul class="list--addinfo"><li class="item"><span class="text--addinfo" style="font-size:12px;font-weight:normal;color:#616161">배송비 3,000원</span></li></ul></div><div class="section--itemcard_info_score"><ul class="list--score"><li class="item awards"><span class="for-a11y">후기평점 4.6점</span><div class="seller_awards" title="후기평점 4.6점"><span class="awards_points" style="width:92%"></span><span class="bg_star"> </span></div></li><li class="item reviewcnt"><span class="text--reviewcnt">후기 <!-- -->62</span><span class="for-a11y">건</span></li><li class="item buycnt"><span class="text--buycnt">구매 <!-- -->226</span><span class="for-a11y">건</span></li></ul></div><div class="section--itemcard_info_shop"><a class="link--shop" href="http://stores.auction.co.kr/scline" target="_blank" title="스토어로 이동" rel="noreferrer"><span class="for-a11y">판매자</span><span class="text">주식회사스카우트라인</span></a></div></div></div></div></ul></body></html>
|
||||
3
lps/tests/fixtures/coupang_search.html
vendored
Normal file
3
lps/tests/fixtures/coupang_search.html
vendored
Normal file
File diff suppressed because one or more lines are too long
3
lps/tests/fixtures/gmarket_search.html
vendored
Normal file
3
lps/tests/fixtures/gmarket_search.html
vendored
Normal file
File diff suppressed because one or more lines are too long
3
lps/tests/fixtures/st11_search.html
vendored
Normal file
3
lps/tests/fixtures/st11_search.html
vendored
Normal file
File diff suppressed because one or more lines are too long
34
lps/tests/test_bot_detection.py
Normal file
34
lps/tests/test_bot_detection.py
Normal file
@ -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
|
||||
146
lps/tests/test_browser_base.py
Normal file
146
lps/tests/test_browser_base.py
Normal file
@ -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(["<PRODUCT>ok</PRODUCT>"])
|
||||
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", "<PRODUCT>ok</PRODUCT>"])
|
||||
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"), "<PRODUCT>ok</PRODUCT>"])
|
||||
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()
|
||||
94
lps/tests/test_coupang_parser.py
Normal file
94
lps/tests/test_coupang_parser.py
Normal file
@ -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"""<li class="ProductUnit_productUnit__x1" data-id="1">
|
||||
<a href="/vp/products/1"><figure><img alt="{name}" src="i.jpg"/></figure>
|
||||
<div class="ProductUnit_productNameV2__x1">{name}</div>
|
||||
<div class="PriceArea_priceArea__x1"><strong>{price}</strong></div>{extra}</a></li>"""
|
||||
|
||||
|
||||
def test_parse_shipping_paid_fee():
|
||||
# 명시 배송비('배송비 3,000원') → paid + 금액
|
||||
html = f"<ul>{_card('AAA 건전지 20개입', '5,880원', '<span>배송비 3,000원</span>')}</ul>"
|
||||
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"<ul>{_card('무료배송 텀블러 굿즈', '12,000원')}</ul>"
|
||||
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("<div id='sec-if-cpt-container'>" + "x" * 20000, 0) == "sec-if-cpt-container"
|
||||
|
||||
|
||||
def test_detect_block_edge_access_denied():
|
||||
# 실제로 잡힌 303B edge deny
|
||||
html = ('<html><head><title>Access Denied</title></head><body><h1>Access Denied</h1>'
|
||||
"You don't have permission to access ... errors.edgesuite.net</body></html>")
|
||||
assert detect_block(html, 0) in ("errors.edgesuite.net", "You don't have permission to access")
|
||||
|
||||
|
||||
def test_detect_block_permission_restricted_page():
|
||||
html = "<html><body>쿠팡을 찾아주신 고객님, 입력하신 페이지주소는 사용권한이 제한된 페이지입니다.</body></html>"
|
||||
assert detect_block(html, 0) in ("사용권한이 제한된", "쿠팡을 찾아주신 고객님")
|
||||
|
||||
|
||||
def test_detect_block_short_html_fallback():
|
||||
# 마커 없어도 비정상적으로 짧은 0건 응답은 미지의 차단으로 폴백
|
||||
marker = detect_block("<html><body>oops</body></html>", 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 = "<html><body>검색결과가 없습니다" + "x" * 20000 + "</body></html>"
|
||||
assert detect_block(big, 0) is None
|
||||
9
lps/tests/test_health.py
Normal file
9
lps/tests/test_health.py
Normal file
@ -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)
|
||||
93
lps/tests/test_job_queue.py
Normal file
93
lps/tests/test_job_queue.py
Normal file
@ -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
|
||||
94
lps/tests/test_lps_api.py
Normal file
94
lps/tests/test_lps_api.py
Normal file
@ -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
|
||||
19
lps/tests/test_naver_transform.py
Normal file
19
lps/tests/test_naver_transform.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""네이버 응답 변환 테스트 (순수, 네트워크 불필요)."""
|
||||
|
||||
from services.search.naver.transform import transform_items
|
||||
|
||||
|
||||
def test_transform_strips_tags_and_keeps_catalog():
|
||||
items = [
|
||||
{"title": "로지텍 <b>무선</b> 마우스 & 키보드", "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 == "로지텍 무선 마우스 & 키보드" # <b> 제거 + 엔티티 복원
|
||||
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) 최저가 유지
|
||||
36
lps/tests/test_negative_cache.py
Normal file
36
lps/tests/test_negative_cache.py
Normal file
@ -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
|
||||
103
lps/tests/test_openmarket_parser.py
Normal file
103
lps/tests/test_openmarket_parser.py
Normal file
@ -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 = ('<div class="box__item-container"><a href="/x"><span class="text__item-title">상품명 물병</span></a>'
|
||||
'<div class="box__price-seller">판매가5,000원</div></div>')
|
||||
html = f"<ul>{card}{card}{card}</ul>"
|
||||
items = parse_cards(html, GMARKET.cards)
|
||||
assert len(items) == 1 and items[0].price == 5000
|
||||
88
lps/tests/test_pipeline.py
Normal file
88
lps/tests/test_pipeline.py
Normal file
@ -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)
|
||||
57
lps/tests/test_pool_autosize.py
Normal file
57
lps/tests/test_pool_autosize.py
Normal file
@ -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
|
||||
93
lps/tests/test_price_history.py
Normal file
93
lps/tests/test_price_history.py
Normal file
@ -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 == [] # 캐시 히트 → 새 관측 없음 → 미기록
|
||||
52
lps/tests/test_proxy.py
Normal file
52
lps/tests/test_proxy.py
Normal file
@ -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
|
||||
241
lps/tests/test_search_handler.py
Normal file
241
lps/tests/test_search_handler.py
Normal file
@ -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
|
||||
139
lps/tests/test_worker.py
Normal file
139
lps/tests/test_worker.py
Normal file
@ -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()
|
||||
46
lps/web_main.py
Normal file
46
lps/web_main.py
Normal file
@ -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,
|
||||
)
|
||||
241
lps/worker/handlers.py
Normal file
241
lps/worker/handlers.py
Normal file
@ -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
|
||||
47
lps/worker/notify.py
Normal file
47
lps/worker/notify.py
Normal file
@ -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
|
||||
99
lps/worker/runner.py
Normal file
99
lps/worker/runner.py
Normal file
@ -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
|
||||
276
lps/worker_main.py
Normal file
276
lps/worker_main.py
Normal file
@ -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"))))
|
||||
Loading…
Reference in New Issue
Block a user