Merge branch 'feature/lps' — 인터넷 최저가 검색(LPS) 시스템

This commit is contained in:
민헌 2026-07-10 13:30:48 +09:00
commit 18f6466a8b
99 changed files with 7891 additions and 0 deletions

21
.env.example Normal file
View 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
View File

@ -24,3 +24,7 @@ CLAUDE.md
# OS
.DS_Store
# 로컬 리서치 노트(크롤링 라이브러리·안티스크래핑 조사) — 추적 안 함, 로컬 참고용
/Temp.md
/new.md

View File

@ -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
View 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>작업 추적 &amp; 상품 정보</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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>&nbsp; 워커가 네이버·쿠팡을 검색하고 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
View 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
View 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
View File

@ -0,0 +1,5 @@
# Chrome 프로필(쿠키·cf_clearance 세션) — 로컬 워커 영속 볼륨. 절대 커밋하지 않는다.
.profiles/
# 로컬 워커 하트비트/부하 테스트 로그(기본은 /tmp 지만 상대경로로 뜰 때 대비)
lps_worker_heartbeat

27
lps/Dockerfile Normal file
View 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
View 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
View 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**.

View 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()

View 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
View 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
View 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()

View 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
View 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
View 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)

View 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)

View 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)

View 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) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정)

View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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)).

View 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
View 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
View 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
View 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
View 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
View 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개"}
]

View 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
View 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")

View 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
View 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
View 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
View 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
View 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)

View 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="시각 오름차순 스냅샷(그래프용)")

View 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))

View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
# services 폴더 placeholder — 도메인 비즈니스 로직(service)을 여기에 추가한다.
# backend/services/*.py 컨벤션: 라우터가 Depends 로 주입받고, DB 접근은 crud + DB_SESSION_MNG 람다로 위임.

View 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

View 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

View 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
View 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,
}

View 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)

View 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]

View 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

View 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

View 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

View 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)

View 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)

View 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

View 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()

View File

View 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

View 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}

View 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

View 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

View 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

View 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()

View File

View 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)

View 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]",
)

View 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

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
lps/tests/fixtures/st11_search.html vendored Normal file

File diff suppressed because one or more lines are too long

View 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

View 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()

View 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
View 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)

View 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
View 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

View 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> 마우스 &amp; 키보드", "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) 최저가 유지

View 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

View 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

View 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)

View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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"))))