Merge commit 'dc8c288f' — lps-admin 신설·LPS 운영기능(ip_session/alerts/admin API)·최저가 출처링크 반영

This commit is contained in:
Mina Choi 2026-07-24 15:30:36 +09:00
commit b539e073cb
86 changed files with 7216 additions and 968 deletions

View File

@ -1,21 +0,0 @@
# 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)

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ CLAUDE.md
/new.md
/mobile.mov
.gstack/

View File

@ -10,6 +10,8 @@
# 솔루션 랜딩: http://localhost:3100
# agent 서버: http://localhost:9500/docs
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
# lps API: http://localhost:9600/docs
# lps admin: http://localhost:3400 (nginx → /v1·/healthz 는 lps-api 로 프록시)
#
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
# psql -h <host> -p <port> -U <user> -f postgres-init/00-init.sql (스키마 전체: negosium_db + 도메인·learning·anchoring schema)
@ -42,6 +44,7 @@ services:
# ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ──
LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API)
LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600
# LPS API guard 키는 negodata 의 config.local.toml [WebServerConfig].lps_api_key 로 관리(개발은 빈값=개방)
volumes:
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
ports:
@ -129,10 +132,7 @@ services:
max-size: "10m"
max-file: "5"
# ── LPS (인터넷 최저가 검색) ──────────────────────────────────
# API(요청 접수, lean) + 워커(크롤, 헤드풀 Chromium+Xvfb). DB 는 외부(host.docker.internal).
# 이미지엔 시크릿이 없다(example config 로 빌드) — 실값은 아래 env 로 주입.
# 시크릿 값은 리포 루트 .env 파일에 채운다(.env.example 참고, .env 는 미커밋).
# LPS API (인터넷 최저가 검색 — 요청 접수/조회, lean). 설정·시크릿은 config.local.toml 하나(미커밋, 마운트).
lps-api:
build:
context: ./lps
@ -140,18 +140,12 @@ services:
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}
DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
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 근처로 상향
volumes:
- ./lps/config/config.local.toml:/app/config/config.local.toml:ro
ports:
- "9600:9600"
- "${LPS_API_BIND:-0.0.0.0}:9600:9600" # prod 는 LPS_API_BIND=127.0.0.1 로 내부만 개방(리버스프록시 뒤)
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
@ -161,38 +155,40 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# LPS 워커 (크롤 — 헤드풀 Chromium+Xvfb, headless 는 안티봇에 탐지됨). config.local.toml 공유.
lps-worker:
build:
context: ./lps
dockerfile: Dockerfile.worker # Chromium + Xvfb (headless 는 안티봇에 탐지됨)
dockerfile: Dockerfile.worker
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}
DB_HOST: ${LPS_DB_HOST-host.docker.internal} # 컨테이너→호스트 DB (config.local.toml 의 127.0.0.1 override)
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/config/config.local.toml:/app/config/config.local.toml:ro
- 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
stop_grace_period: 75s # graceful 종료 유예(worker shutdown_grace_sec=60 + 여유)
labels:
autoheal: "true" # 하트비트 HEALTHCHECK 실패(행/좀비) 시 autoheal 이 재시작
autoheal: "true" # 하트비트 실패(행/좀비) 시 autoheal 재시작
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# LPS 관리자 UI (정적 React → nginx). /v1·/healthz·/readyz 는 nginx 가 lps-api:9600 으로 프록시(앱은 상대경로 호출).
lps-admin:
build:
context: ./lps-admin
dockerfile: Dockerfile
container_name: lps-admin
ports:
- "3400:80"
depends_on:
- lps-api
restart: unless-stopped
logging:
driver: json-file

5
lps-admin/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.git
*.local
tsconfig.tsbuildinfo

3
lps-admin/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
*.local

26
lps-admin/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# LPS 관리자 UI 이미지 — Vite(React) 정적 빌드 → nginx 서빙.
# nginx 가 /v1·/healthz·/readyz 를 lps-api:9600 으로 프록시(동일출처)하므로,
# 앱은 상대경로로 API 를 호출한다(client.ts baseUrl 빈값) → 빌드 타임 API URL 주입 불필요.
# 빌드는 slim(glibc) — alpine(musl)+esbuild 의 ETXTBSY 회피(negodata/front 선례).
FROM node:22-slim AS build
WORKDIR /app
# 의존성 먼저 (레이어 캐시). package-lock 고정 설치.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # tsc -b && vite build → dist/
# ── 런타임: 정적 서빙 + API 프록시 ──
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# UI 자체 도달성만 확인(index.html) — API 상태는 앱 대시보드가 lps-api 를 직접 폴링한다.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1

38
lps-admin/README.md Normal file
View File

@ -0,0 +1,38 @@
# LPS 관리자 페이지
LPS(인터넷 최저가 검색)의 운영 지표를 보고 필수 액션(수동 재검색·DEAD 재큐)을 수행하는
React 관리자 페이지입니다. 서버 동작 설정은 여기서 바꾸지 않습니다 — 설정 소스는 서버의
`config.<env>.toml` 하나(2026-07-13 협의).
## 실행 (개발)
```bash
npm install
npm run dev # http://localhost:5174 — /v1·/healthz 는 vite 프록시로 :9600 에 전달(CORS 불필요)
```
LPS API 를 먼저 띄워두세요: `cd ../lps && ./run_local_server.sh`.
다른 호스트의 API 를 보려면 `VITE_LPS_URL=http://<host>:9600 npm run dev` 또는
앱의 **설정** 페이지에서 API 주소를 지정합니다(guard 켜진 prod 는 API 키도 입력 — X-API-Key 자동 첨부).
## 페이지
| 페이지 | 내용 | 데이터 |
|---|---|---|
| 대시보드 | 큐/차단/비용/DB풀 스탯 타일 + 임계 배너 + 큐 추이(5초 폴링) | `GET /v1/lps/ops` |
| 작업 큐 | 목록(상태·검색 필터)·상세(결과/원가/오류)·**DEAD 재큐** | `/v1/lps/jobs`, `/jobs/{id}/requeue` |
| 상품·가격 | 가격 추이 3선(네이버·쿠팡·최종)·몰별 최저가·검증 링크·**지금 다시 검색** | `/v1/lps/products`, `/products/{code}/history`, `POST /search` |
| 크롤 상태 | IP 세션 종료사유 도넛·요청 수 분포(+차단 시작 기준선 = 예산 튜닝 뷰)·차단 이력 | `/v1/lps/stats/ip-sessions`, `/stats/bot` |
| 비용 | 시간별 원가 스택(AI vs 프록시)·건당 평균·평균 소요 | `/v1/lps/stats/cost` |
| 테스트 검색 | 임의 상품을 실제 파이프라인에 흘려 셀렉터·프록시·AI 매칭 점검 — 진행 스텝퍼·파이프라인 퍼널·소스별·검색 원가·매칭 상품 | `POST /search` + `GET /jobs/{id}` 폴링 |
> **테스트 검색은 실제 크롤이라 프록시 비용이 발생**하고 결과가 비용 통계에 집계됩니다. 워커가 떠 있어야
> 동작합니다(없으면 대기에서 멈춤). 상품코드는 `TEST-<시각>` 자동 생성 — 작업 큐의 **"테스트만"** 필터로
> 걸러볼 수 있고, negodata 는 UUID 코드만 매핑하므로 연동에 영향이 없습니다.
## 스택·규칙
- Vite + React + TS + Tailwind v4 + recharts + @tanstack/react-query + react-router (negodata 프론트와 동일 계열, 경량)
- 색 토큰은 숫자 스케일(`src/index.css` @theme). 차트 시리즈 색은 **엔터티 고정** —
네이버 `#008a43`(적록색약 분리 검증 통과 톤) · 쿠팡 `#f0455b` · 최종 `#4f46e5`. 순서·색 재배정 금지.
- 빌드: `npm run build` (tsc 포함) → `dist/`

12
lps-admin/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LPS 관리자 — 인터넷 최저가 검색</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

51
lps-admin/nginx.conf Normal file
View File

@ -0,0 +1,51 @@
# LPS 관리자 UI — 정적 SPA 서빙 + LPS API 동일출처 프록시.
# 브라우저는 이 오리진(:3400)만 보고, /v1·/healthz·/readyz 는 여기서 lps-api:9600 으로 넘긴다.
# (vite dev 서버의 proxy 설정과 동일한 대상·경로 — 개발/운영 동작 일치)
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip — 번들·JSON 응답 전송량 절감
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# ── API 프록시 (lps-api:9600) ──
# 도커 임베디드 DNS(127.0.0.11)로 매 요청 재해석 → lps-api 재시작(새 IP)에도 502 없이 따라간다.
# proxy_pass 에 경로를 두지 않아 원본 요청 URI 를 그대로 전달한다(/v1/lps/ops → 동일 경로).
resolver 127.0.0.11 valid=30s ipv6=off;
location /v1/ {
set $lps_api http://lps-api:9600;
proxy_pass $lps_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # 테스트 검색 등 긴 폴링 여유
}
location = /healthz {
set $lps_api http://lps-api:9600;
proxy_pass $lps_api;
}
location = /readyz {
set $lps_api http://lps-api:9600;
proxy_pass $lps_api;
}
# ── SPA 라우팅 — 정적 파일 없으면 index.html 로 폴백 ──
location / {
try_files $uri $uri/ /index.html;
}
# 정적 자산 캐시(해시 파일명이라 장기 캐시 안전). index.html 은 캐시 안 함.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2795
lps-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
lps-admin/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "lps-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.0",
"@tanstack/react-query": "^5.62.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.1.0",
"recharts": "^2.15.0",
"tailwindcss": "^4.1.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.3",
"vite": "^6.0.7"
}
}

View File

@ -0,0 +1,46 @@
/** fetch 래퍼 — base URL·X-API-Key(설정 페이지, localStorage) 자동 적용 + 봉투(result) 검사. */
const BASE_KEY = "lps.baseUrl";
const API_KEY = "lps.apiKey";
export const settings = {
baseUrl: () => localStorage.getItem(BASE_KEY) ?? "",
apiKey: () => localStorage.getItem(API_KEY) ?? "",
save(baseUrl: string, apiKey: string) {
baseUrl ? localStorage.setItem(BASE_KEY, baseUrl.replace(/\/$/, "")) : localStorage.removeItem(BASE_KEY);
apiKey ? localStorage.setItem(API_KEY, apiKey) : localStorage.removeItem(API_KEY);
},
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...((init?.headers as Record<string, string>) ?? {}) };
const key = settings.apiKey();
if (key) headers["X-API-Key"] = key;
const res = await fetch(settings.baseUrl() + path, { ...init, headers });
if (res.status === 401) throw new Error("인증 실패(401) — 설정에서 API 키를 확인하세요");
if (!res.ok) throw new Error(`서버 오류 (HTTP ${res.status})`);
const body = (await res.json()) as T & { result?: { success: boolean; desc?: string } };
if (body?.result && body.result.success === false) {
throw new Error(body.result.desc || "요청이 거절됐습니다");
}
return body;
}
export const get = <T>(path: string) => request<T>(path);
export const post = <T>(path: string, payload?: unknown) =>
request<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload === undefined ? undefined : JSON.stringify(payload),
});
/** healthz 는 JSON 문자열("시각")을 반환 — 도달 여부만 본다. */
export async function healthz(): Promise<boolean> {
try {
const res = await fetch(settings.baseUrl() + "/healthz");
return res.ok;
} catch {
return false;
}
}

213
lps-admin/src/api/types.ts Normal file
View File

@ -0,0 +1,213 @@
/** LPS API 응답 타입 — 백엔드 protocol/admin_protocol 미러링. */
export interface ResultEnvelope {
success: boolean;
code: number;
desc: string;
}
export interface OpsSnapshot {
pending: number;
running: number;
done: number;
dead: number;
dead_1h: number;
stuck_running: number;
oldest_pending_sec: number;
blocks_1h: number;
deadline_1h: number;
cost_1h_usd: number;
pool_checked_out: number;
pool_capacity: number;
pool_pct: number;
}
export type JobStatusName = "PENDING" | "RUNNING" | "DONE" | "DEAD";
export interface JobListItem {
job_id: string;
job_type: number;
status: JobStatusName;
priority: number;
attempts: number;
max_attempts: number;
product_code?: string;
product_name?: string;
outcome?: string;
final_lowest?: number;
cost_usd?: number;
last_error?: string;
created_at: string;
run_started_at?: string;
updated_at: string;
}
export interface JobListRes {
result: ResultEnvelope;
items: JobListItem[];
total: number;
}
export interface JobDetailRes {
result: ResultEnvelope;
job_id?: string;
status?: JobStatusName;
attempts?: number;
max_attempts?: number;
output?: JobOutput;
last_error?: string;
}
export interface RequeueRes {
result: ResultEnvelope;
job_id?: string;
requeued: boolean;
}
export interface ProductItem {
product_code: string;
display_name?: string;
triggered_at: string;
outcome: string;
naver_lowest?: number;
coupang_lowest?: number;
final_lowest?: number;
final_source?: string;
searches: number;
}
export interface ProductListRes {
result: ResultEnvelope;
items: ProductItem[];
}
/** 몰별 최저가 스냅샷 원소(price_history.by_mall) — summarize_by_mall 출력과 정합. */
export interface MallEntry {
source?: string;
mall_name?: string;
price?: number;
shipping_fee?: number | null;
shipping_type?: string | null;
name?: string;
detail_url?: string;
}
export interface PricePoint {
triggered_at: string;
outcome: string;
matched_count?: number;
naver?: number;
coupang?: number;
final?: number;
final_source?: string;
naver_name?: string;
naver_url?: string;
coupang_name?: string;
coupang_url?: string;
by_mall?: MallEntry[];
}
export interface PriceHistoryRes {
result: ResultEnvelope;
product_code?: string;
points: PricePoint[];
}
export interface SearchRes {
result: ResultEnvelope;
accepted: number;
items: { product_code: string; job_id?: string; duplicated: boolean }[];
}
/** 정규화 상품(어댑터 출력) — 잡 결과의 lowest/top 원소. */
export interface NormalizedProduct {
source: string;
name: string;
price: number;
mall_name?: string;
detail_url?: string;
shipping_fee?: number | null;
shipping_type?: string | null;
}
export interface PipelineStage {
stage: string;
in: number;
out: number;
}
export interface SearchMetrics {
duration_ms?: number;
ai?: { calls: number; prompt_tokens: number; completion_tokens: number; est_cost_usd: number };
crawl?: { fetches: number; html_bytes: number; proxy_bytes: number; malls_crawled: string[] };
cost?: { ai_usd: number; proxy_usd: number; total_usd: number };
source_ms?: Record<string, number>;
}
/** 잡 결과(output) — 크롤 테스트가 소비하는 파이프라인 산출물. */
export interface JobOutput {
outcome?: "found" | "not_found";
query?: string;
round?: string;
rounds_tried?: number;
cached?: boolean;
total_found?: number;
kept?: number;
lowest?: NormalizedProduct | null;
top?: NormalizedProduct[];
by_mall?: MallEntry[];
stages?: PipelineStage[];
// 소스별 수집 결과 — 정상이면 {count}, 기술적 실패(차단·예외)면 {error}.
sources?: Record<string, { count?: number; error?: string }>;
metrics?: SearchMetrics;
}
export interface IpSessionRow {
source: string;
proxy_port?: number;
requests: number;
ok_count: number;
blocked_count: number;
elapsed_sec?: number;
end_reason: string;
created_at: string;
}
export interface IpSessionStatsRes {
result: ResultEnvelope;
by_reason: Record<string, number>;
histogram: { requests: number; count: number }[];
block_min_requests?: number | null;
sessions: IpSessionRow[];
}
export interface BotRow {
source: string;
query?: string;
ip_request_no?: number;
proxy_port?: number;
elapsed_sec?: number;
marker?: string;
html_len?: number;
created_at: string;
}
export interface BotStatsRes {
result: ResultEnvelope;
hourly: { bucket: string; count: number }[];
items: BotRow[];
}
export interface CostBucket {
bucket: string;
jobs: number;
ai_usd: number;
proxy_usd: number;
total_usd: number;
avg_ms: number;
}
export interface CostStatsRes {
result: ResultEnvelope;
buckets: CostBucket[];
}

View File

@ -0,0 +1,69 @@
/** 앱 레이아웃 — 좌측 네비 + 상단 헬스 배지. */
import { useQuery } from "@tanstack/react-query";
import { NavLink, Outlet } from "react-router";
import { healthz } from "../api/client";
// action: 실제 비용이 드는 실행 페이지(모니터링과 성격이 다름) — 다른 메뉴와 배경색(앰버)만 다르게.
const MENU = [
{ to: "/", label: "대시보드", end: true },
{ to: "/jobs", label: "작업 큐" },
{ to: "/products", label: "상품·가격" },
{ to: "/crawl", label: "크롤 상태" },
{ to: "/costs", label: "비용" },
{ to: "/test", label: "테스트 검색", action: true },
{ to: "/settings", label: "설정" },
];
export default function Layout() {
const health = useQuery({ queryKey: ["healthz"], queryFn: healthz, refetchInterval: 10_000 });
const up = health.data === true;
return (
<div className="mx-auto flex h-dvh w-full max-w-[1280px] flex-col overflow-hidden md:flex-row">
{/* 모바일(<md): 상단 바 + 가로 스크롤 네비(터치 44px). md 이상: 좌측 고정 사이드바. */}
<aside className="w-full min-w-0 shrink-0 border-b border-line-200 px-3 py-3 md:w-[196px] md:border-b-0 md:border-r md:py-5">
<div className="flex items-center justify-between md:block">
<div className="flex items-center gap-2.5 px-2 md:mb-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-400 text-[14px] font-extrabold text-white">L</span>
<div>
<div className="text-[14px] font-bold leading-tight">LPS 관리자</div>
<div className="text-[11px] font-medium text-ink-400">인터넷 최저가 검색</div>
</div>
</div>
{/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */}
<div className="shrink-0 px-2 md:mb-5">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${up ? "bg-ok-50 text-ok-600" : "bg-dead-50 text-dead-600"}`}>
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-ok-600" : "bg-dead-600"}`} aria-hidden />
{up ? "API 연결됨" : "API 연결 안 됨"}
</span>
</div>
</div>
<nav aria-label="주 메뉴" className="mt-2 md:mt-0">
<ul className="flex items-center gap-1 overflow-x-auto md:flex-col md:items-stretch md:gap-0 md:space-y-0.5">
{MENU.map((m) => (
<li key={m.to} className="flex shrink-0 items-center">
<NavLink
to={m.to}
end={m.end}
className={({ isActive }) =>
`flex min-h-[44px] w-full items-center whitespace-nowrap rounded-lg px-3 text-[13px] font-semibold transition-colors md:min-h-0 md:py-2 ${
m.action
? isActive ? "bg-warn-600/20 text-warn-700" : "bg-warn-600/10 text-ink-700 hover:bg-warn-600/20"
: isActive ? "bg-primary-50 text-primary-700" : "text-ink-700 hover:bg-surface-2"
}`
}
>
{m.label}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
{/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 py-4 md:px-6 md:py-5">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,219 @@
/** 공용 UI 조각 — 카드·스탯 타일·상태 배지·빈/오류 표시 + 페이지 공통 컨트롤
* (헤더·세그먼트 탭·버튼·검색폼·범례·고정높이 표/스크롤). 웹 표준 시맨틱 마크업 유지. */
import { useEffect } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import type { JobStatusName } from "../api/types";
// fill: 카드가 부모 높이를 채우고 본문이 남는 높이를 flex 로 차지(그래프·표가 높이 채우기용).
// compact: 패딩을 줄여 밀도 높은 레이아웃(한 화면에 많이 담기).
export function Card({ title, hint, children, className = "", fill = false, compact = false }: {
title?: ReactNode; hint?: ReactNode; children: ReactNode; className?: string; fill?: boolean; compact?: boolean;
}) {
return (
<section className={`rounded-xl border border-line-200 bg-surface shadow-[0_1px_2px_rgba(16,24,40,.04)] ${fill ? "flex min-h-0 flex-col" : ""} ${className}`}>
{title !== undefined && (
<header className={`flex shrink-0 items-center gap-2 border-b border-line-100 ${compact ? "px-3 py-2" : "px-4 py-2.5"}`}>
<h2 className="text-[13px] font-bold tracking-tight">{title}</h2>
{hint && <div className="ml-auto text-[11px] text-ink-400">{hint}</div>}
</header>
)}
<div className={`${compact ? "p-3" : "p-4"} ${fill ? "flex min-h-0 flex-1 flex-col" : ""}`}>{children}</div>
</section>
);
}
export function StatTile({ label, value, sub, tone = "default" }: {
label: string; value: ReactNode; sub?: ReactNode;
tone?: "default" | "ok" | "warn" | "run" | "dead";
}) {
const toneCls = {
default: "text-ink-900",
ok: "text-ok-600",
warn: "text-warn-600",
run: "text-run-600",
dead: "text-dead-600",
}[tone];
return (
<div className="rounded-xl border border-line-200 bg-surface px-4 py-3">
<div className="text-[12px] font-semibold text-ink-500">{label}</div>
<div className={`mt-0.5 text-[22px] font-bold leading-tight ${toneCls}`}>{value}</div>
{sub && <div className="mt-0.5 text-[11px] text-ink-400">{sub}</div>}
</div>
);
}
const STATUS_STYLE: Record<JobStatusName, { dot: string; bg: string; text: string; label: string }> = {
PENDING: { dot: "bg-warn-600", bg: "bg-warn-50", text: "text-warn-700", label: "대기" },
RUNNING: { dot: "bg-run-600", bg: "bg-run-50", text: "text-run-600", label: "처리중" },
DONE: { dot: "bg-ok-600", bg: "bg-ok-50", text: "text-ok-600", label: "완료" },
DEAD: { dot: "bg-dead-600", bg: "bg-dead-50", text: "text-dead-600", label: "실패" },
};
export function StatusBadge({ status }: { status: JobStatusName }) {
const s = STATUS_STYLE[status];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${s.bg} ${s.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${s.dot}`} aria-hidden />
{s.label}
</span>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="py-8 text-center text-[13px] text-ink-400">{children}</p>;
}
export function ErrorNote({ error }: { error: unknown }) {
return (
<p role="alert" className="rounded-lg bg-dead-50 px-3 py-2 text-[13px] font-medium text-dead-600">
{error instanceof Error ? error.message : "요청에 실패했습니다"}
</p>
);
}
export function Loading() {
return <p className="py-8 text-center text-[13px] text-ink-400" aria-busy="true">불러오는 중…</p>;
}
// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ──
export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<h1 className="text-[17px] font-bold tracking-tight">{title}</h1>
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}
// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ──
export function Segmented<T extends string | number>({ options, value, onChange, ariaLabel, size = "md" }: {
options: readonly { v: T; label: ReactNode }[];
value: T; onChange: (v: T) => void; ariaLabel: string; size?: "sm" | "md";
}) {
const cell = size === "sm" ? "px-2 py-0.5 text-[11px]" : "px-3 py-1.5 text-[12px]";
return (
<div role="tablist" aria-label={ariaLabel} className="flex rounded-lg border border-line-200 bg-surface p-0.5">
{options.map((o) => (
<button key={String(o.v)} role="tab" aria-selected={value === o.v} onClick={() => onChange(o.v)}
className={`rounded-md font-semibold ${cell} ${value === o.v ? "bg-primary-50 text-primary-700" : "text-ink-500 hover:text-ink-700"}`}>
{o.label}
</button>
))}
</div>
);
}
// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ──
export function Button({ variant = "primary", size = "md", className = "", ...props }: {
variant?: "primary" | "outline"; size?: "sm" | "md" | "lg";
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const sizes = { sm: "px-2.5 py-1 text-[12px]", md: "px-3 py-1.5 text-[13px]", lg: "px-4 py-2 text-[13px]" };
const variants = {
primary: "bg-primary-600 text-white hover:bg-primary-700",
outline: "border border-line-200 text-ink-700 hover:bg-line-200 hover:text-ink-900",
};
return <button className={`cursor-pointer rounded-lg font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${sizes[size]} ${variants[variant]} ${className}`} {...props} />;
}
// ── 검색 폼 — input + 검색 버튼(Jobs·Products 동일 폼 통일). ──
export function SearchForm({ value, onChange, onSubmit, placeholder, className = "", inputClassName = "w-64" }: {
value: string; onChange: (v: string) => void; onSubmit: () => void;
placeholder: string; className?: string; inputClassName?: string;
}) {
return (
<form className={`flex gap-1.5 ${className}`} onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} aria-label={placeholder}
className={`rounded-lg border border-line-200 bg-surface px-3 py-1.5 text-[13px] outline-none focus:border-primary-600 ${inputClassName}`} />
<Button type="submit">검색</Button>
</form>
);
}
// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ──
export function Legend({ items, note }: {
items: readonly { label: ReactNode; color: string }[]; note?: ReactNode;
}) {
return (
<p className="mt-1 flex flex-wrap gap-4 text-[11px] font-semibold text-ink-500">
{items.map((it, i) => (
<span key={i}>
<span className="mr-1 inline-block h-2 w-2 rounded-full" style={{ background: it.color }} aria-hidden />
{it.label}
</span>
))}
{note && <span className="ml-auto font-medium text-ink-400">{note}</span>}
</p>
);
}
// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ──
export function ScrollBox({ height = 420, className = "", children }: {
height?: number | "fill"; className?: string; children: ReactNode;
}) {
const fill = height === "fill";
return (
<div className={`overflow-y-auto ${fill ? "min-h-0 flex-1" : ""} ${className}`}
style={fill ? undefined : { height }}>{children}</div>
);
}
// ── 모달 — 배경 딤 + Esc/바깥클릭 닫기. actions 로 헤더 우측 버튼(복사 등) 배치. ──
export function Modal({ title, onClose, actions, children }: {
title: ReactNode; onClose: () => void; actions?: ReactNode; children: ReactNode;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink-900/30" onClick={onClose} aria-hidden />
<div role="dialog" aria-modal="true"
className="relative flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-line-200 bg-surface shadow-[0_10px_40px_rgba(16,24,40,.18)]">
<header className="flex shrink-0 items-center gap-2 border-b border-line-100 px-4 py-3">
<h2 className="text-[14px] font-bold tracking-tight">{title}</h2>
<div className="ml-auto flex items-center gap-2">
{actions}
<button onClick={onClose} aria-label="닫기"
className="grid h-7 w-7 place-items-center rounded-md text-ink-400 hover:bg-surface-2 hover:text-ink-700">✕</button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
</div>
</div>
);
}
// ── 표 — thead sticky. height 숫자=고정 높이, "fill"=남는 높이 채움, "auto"=내용 높이.
// fixed=table-fixed(좁은 폭에서 열 넘침·줄바꿈 방지), 열별 width 지정 가능. ──
export function ScrollTable({ height = 420, dense = false, fixed = false, columns, children }: {
height?: number | "fill" | "auto"; dense?: boolean; fixed?: boolean;
columns: readonly { key: string; label: ReactNode; align?: "right"; width?: string }[];
children: ReactNode;
}) {
const pad = dense ? "pb-1.5 pr-2 pt-1.5" : "pb-2 pr-3 pt-2";
const fill = height === "fill";
const auto = height === "auto";
// 행 좌우 여백: 첫/마지막 셀에 padding(헤더·본문 공통). 헤더는 bg-surface-2 밴드로 카드 제목바와 구분.
const edge = dense ? "[&_th:first-child]:pl-3 [&_td:first-child]:pl-3 [&_th:last-child]:pr-3 [&_td:last-child]:pr-3"
: "[&_th:first-child]:pl-4 [&_td:first-child]:pl-4 [&_th:last-child]:pr-4 [&_td:last-child]:pr-4";
return (
<div className={`overflow-auto ${fill ? "min-h-0 flex-1" : ""}`} style={fill || auto ? undefined : { height }}>
<table className={`w-full text-left ${fixed ? "table-fixed" : ""} ${dense ? "text-[12px]" : "text-[13px]"} ${edge}`}>
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} style={c.width ? { width: c.width } : undefined}
className={`sticky top-0 z-10 whitespace-nowrap border-b border-line-200 bg-surface-2 text-[11px] font-semibold text-ink-500 ${pad} ${c.align === "right" ? "text-right" : ""}`}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}

75
lps-admin/src/index.css Normal file
View File

@ -0,0 +1,75 @@
@import "tailwindcss";
/* 색 토큰 — 숫자 스케일(팀 관례) + dataviz 차트 크롬. 시리즈 색은 엔터티 고정:
네이버 #008a43(CVD 검증 통과 톤다운) · 쿠팡 #f0455b · 최종 #4f46e5. */
@theme {
--color-page: #f7f7f5;
--color-surface: #fcfcfb;
--color-surface-2: #f4f4f1;
--color-line-200: #e6e5df;
--color-line-100: #efeee9;
--color-grid: #e1e0d9;
--color-ink-900: #171a20;
--color-ink-700: #3d434f;
/* ink-500/400: 소형(11~13px) 캡션·라벨에 쓰이므로 WCAG AA 4.5:1 이상 필수
(기존 ink-400 #9aa1ac 은 2.54:1 로 미달 — 위계는 크기·굵기가 담당) */
--color-ink-500: #5d6470;
--color-ink-400: #6d7480;
--color-primary-700: #4338ca;
--color-primary-600: #4f46e5;
--color-primary-400: #8b7bff;
--color-primary-100: #e5e4fb;
--color-primary-50: #eef0ff;
/* 상태(큐·알림) — status 팔레트, 시리즈로 재사용 금지 */
--color-ok-600: #0f9d58;
--color-ok-50: #e7f6ee;
--color-warn-700: #a36a00;
--color-warn-600: #c77800;
--color-warn-50: #fdf3e3;
--color-run-600: #2563eb;
--color-run-50: #e8f0ff;
--color-dead-600: #d03b3b;
--color-dead-50: #fdeaea;
/* 중립 계열(차트 보조 — 유휴/종료 등 '무해한 상태') */
--color-neutral-400: #898781;
--color-neutral-300: #c3c2b7;
/* 차트 시리즈(엔터티 고정) */
--color-naver: #008a43;
--color-coupang: #f0455b;
--color-final: #4f46e5;
--color-cost-ai: #2a78d6;
--color-cost-proxy: #eda100;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
}
/* 앱을 뷰포트에 고정 — 페이지(body) 스크롤 없음. 스크롤은 content(main) 영역에서만. */
html, body, #root {
height: 100%;
}
body {
@apply bg-page text-ink-900 font-sans text-[14px] antialiased;
}
/* Tailwind v4 는 버튼 기본 커서를 default 로 바꿈 — 활성 버튼은 pointer 로 복구. */
button:not(:disabled) {
cursor: pointer;
}
/* 테이블 숫자 열 정렬 */
.tnum {
font-variant-numeric: tabular-nums;
}
/* recharts 기본 폰트 */
.recharts-wrapper,
.recharts-tooltip-wrapper {
font-size: 12px;
}

View File

@ -0,0 +1,35 @@
/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화.
* 색은 index.css @theme 토큰 참조(단일 소스). 사용: <XAxis dataKey="x" {...xAxisProps} minTickGap={40} /> */
export const gridProps = { stroke: "var(--color-grid)", vertical: false } as const;
// X축: 하단 눈금 + 옅은 축선(기준선 역할).
export const xAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: { stroke: "var(--color-line-200)" },
} as const;
// Y축: 눈금만(축선 없음 — 그리드가 대신함).
export const yAxisProps = {
tick: { fill: "var(--color-ink-400)" },
tickLine: false,
axisLine: false,
} as const;
// 툴팁 — 둥근 카드·부드러운 그림자·토큰 색(기본 recharts 툴팁을 앱 톤으로 통일).
// 사용: <Tooltip {...tooltipProps} formatter={...} /> (막대엔 cursor={barCursor} 추가)
export const tooltipProps = {
contentStyle: {
borderRadius: 12,
border: "1px solid var(--color-line-200)",
background: "var(--color-surface)",
boxShadow: "0 8px 24px rgba(16,24,40,.14)",
padding: "8px 12px",
},
labelStyle: { color: "var(--color-ink-500)", fontWeight: 700, marginBottom: 4, fontSize: 12 },
itemStyle: { padding: "1px 0", fontSize: 12 },
} as const;
// 막대 hover 커서 — 옅은 배경 하이라이트(기본 회색 박스 대신).
export const barCursor = { fill: "color-mix(in srgb, var(--color-ink-900) 4%, transparent)" } as const;

View File

@ -0,0 +1,29 @@
/** 표시 포맷 유틸 — 원화·달러·상대시각·소요시간. */
export const won = (v?: number | null) =>
v === null || v === undefined ? "—" : `${v.toLocaleString("ko-KR")}원`;
export const usd = (v?: number | null, digits = 3) =>
v === null || v === undefined ? "—" : `$${v.toFixed(digits)}`;
export function timeAgo(iso?: string | null): string {
if (!iso) return "—";
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return `${Math.floor(sec)}초 전`;
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 전`;
return `${Math.floor(sec / 86400)}일 전`;
}
export const hhmm = (iso: string) =>
new Date(iso).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", hour12: false });
export const dateShort = (iso: string) =>
new Date(iso).toLocaleString("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
export function durationSec(sec?: number | null): string {
if (sec === null || sec === undefined) return "—";
if (sec < 60) return `${sec}초`;
if (sec < 3600) return `${Math.floor(sec / 60)}분 ${sec % 60}초`;
return `${Math.floor(sec / 3600)}시간 ${Math.floor((sec % 3600) / 60)}분`;
}

41
lps-admin/src/main.tsx Normal file
View File

@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Jobs from "./pages/Jobs";
import Products from "./pages/Products";
import Crawl from "./pages/Crawl";
import Costs from "./pages/Costs";
import TestSearch from "./pages/TestSearch";
import Settings from "./pages/Settings";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ path: "/", element: <Dashboard /> },
{ path: "/jobs", element: <Jobs /> },
{ path: "/products", element: <Products /> },
{ path: "/crawl", element: <Crawl /> },
{ path: "/costs", element: <Costs /> },
{ path: "/test", element: <TestSearch /> },
{ path: "/settings", element: <Settings /> },
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);

View File

@ -0,0 +1,100 @@
/** 비용 — 시간별 검색원가 스택(AI vs 프록시) + 평균 소요 시간(별도 축이므로 별도 차트). */
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get } from "../api/client";
import type { CostStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Legend, Loading, PageHeader, Segmented, StatTile } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, usd } from "../lib/format";
const RANGES = [
{ v: 24, label: "24시간" },
{ v: 48, label: "48시간" },
{ v: 168, label: "7일" },
];
export default function Costs() {
const [hours, setHours] = useState(48);
const cost = useQuery({
queryKey: ["cost", hours],
queryFn: () => get<CostStatsRes>(`/v1/lps/stats/cost?hours=${hours}`),
refetchInterval: 60_000,
});
const buckets = (cost.data?.buckets ?? []).map((b) => ({ ...b, x: dateShort(b.bucket) }));
const totals = buckets.reduce(
(acc, b) => ({ jobs: acc.jobs + b.jobs, ai: acc.ai + b.ai_usd, proxy: acc.proxy + b.proxy_usd, total: acc.total + b.total_usd }),
{ jobs: 0, ai: 0, proxy: 0, total: 0 },
);
const perSearch = totals.jobs > 0 ? totals.total / totals.jobs : null;
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="비용">
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
</PageHeader>
{cost.isError && <ErrorNote error={cost.error} />}
<div className="grid shrink-0 grid-cols-2 gap-3 md:grid-cols-4">
<StatTile label="완료 검색" value={totals.jobs} sub="기간 내" />
<StatTile label="총 비용" value={usd(totals.total, 2)} sub={`AI ${usd(totals.ai, 2)} + 프록시 ${usd(totals.proxy, 2)}`} />
<StatTile label="검색 1건당" value={perSearch === null ? "—" : usd(perSearch)} sub="평균 원가" />
<StatTile label="프록시 비중" value={totals.total > 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" />
</div>
<Card fill className="flex-[3]" title="시간별 검색원가" hint="AI vs 프록시 대역폭">
{cost.isPending && <Loading />}
{cost.data && buckets.length === 0 && (
<div className="grid h-full place-items-center"><Empty>기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다</Empty></div>
)}
{buckets.length > 0 && (
<>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number, name: string) => [usd(v), name]}
labelFormatter={(l, payload) => {
const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs;
return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`;
}} />
<Bar dataKey="ai_usd" name="AI" stackId="c" fill="var(--color-cost-ai)" maxBarSize={28} isAnimationActive={false} />
<Bar dataKey="proxy_usd" name="프록시" stackId="c" fill="var(--color-cost-proxy)" radius={[4, 4, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<Legend items={[
{ label: "AI (판정·검색어)", color: "var(--color-cost-ai)" },
{ label: "프록시 대역폭 (DECODO)", color: "var(--color-cost-proxy)" },
]} />
</>
)}
</Card>
<Card fill className="flex-[2]" title="검색 1건 평균 소요 시간" hint="시간별 · 상승하면 경합/차단 신호">
{buckets.length === 0 ? (
<div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>
) : (
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={buckets} margin={{ top: 6, right: 12, bottom: 0, left: -10 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}s`} />
<Tooltip {...tooltipProps} formatter={(v: number) => [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} />
<Line type="monotone" dataKey="avg_ms" name="평균 소요" stroke="var(--color-primary-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,236 @@
/** 크롤 상태 — IP 세션 종료 사유·요청 수 분포(예산 튜닝 뷰)·차단 이력. */
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get } from "../api/client";
import type { BotStatsRes, IpSessionStatsRes } from "../api/types";
import { Card, Empty, ErrorNote, Loading, PageHeader, ScrollTable, Segmented } from "../components/ui";
import { barCursor, gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, durationSec, hhmm } from "../lib/format";
const BOT_COLS = [
{ key: "time", label: "시각" },
{ key: "query", label: "검색어" },
{ key: "reqno", label: "요청#", align: "right" },
{ key: "port", label: "포트", align: "right" },
{ key: "marker", label: "감지 근거" },
] as const;
const SESSION_COLS = [
{ key: "end", label: "종료 시각" },
{ key: "source", label: "소스" },
{ key: "port", label: "포트", align: "right" },
{ key: "req", label: "요청", align: "right" },
{ key: "okblock", label: "성공/차단", align: "right" },
{ key: "dur", label: "지속", align: "right" },
{ key: "reason", label: "종료 사유" },
] as const;
// 종료 사유 — 의미 기반 상태색. 색은 전부 index.css @theme 토큰 참조(단일 소스) —
// raw hex 재정의 금지(토큰을 바꾸면 여기도 함께 바뀌어야 한다).
const REASONS: Record<string, { label: string; color: string; desc: string }> = {
budget: { label: "예산 선제 회전", color: "var(--color-ok-600)", desc: "정상 — 차단 전에 IP 교체(평판 보존)" },
window: { label: "시간창 만료", color: "var(--color-run-600)", desc: "정상 — sticky 10분 주기 교체" },
idle: { label: "유휴 정리", color: "var(--color-neutral-400)", desc: "정상 — 한동안 검색 없어 브라우저 회수" },
shutdown: { label: "종료", color: "var(--color-neutral-300)", desc: "정상 — 워커 재시작/종료" },
rotate: { label: "기타 회전", color: "var(--color-ink-400)", desc: "웜업 재시도 등" },
proxy_error: { label: "포트 사망", color: "var(--color-warn-600)", desc: "주의 — 프록시 전송 실패로 교체" },
block: { label: "차단됨", color: "var(--color-dead-600)", desc: "위험 — 예산 안에서도 차단(예산 하향 검토)" },
};
const RANGES = [
{ v: 24, label: "24시간" },
{ v: 168, label: "7일" },
{ v: 720, label: "30일" },
];
export default function Crawl() {
const [hours, setHours] = useState(168);
const ip = useQuery({
queryKey: ["ip-sessions", hours],
queryFn: () => get<IpSessionStatsRes>(`/v1/lps/stats/ip-sessions?hours=${hours}`),
refetchInterval: 30_000,
});
const bot = useQuery({
queryKey: ["bot", hours],
queryFn: () => get<BotStatsRes>(`/v1/lps/stats/bot?hours=${hours}`),
refetchInterval: 30_000,
});
const reasons = Object.entries(ip.data?.by_reason ?? {})
.map(([k, v]) => ({ key: k, ...(REASONS[k] ?? { label: k, color: "var(--color-ink-400)", desc: "" }), value: v }))
.sort((a, b) => b.value - a.value);
const totalSessions = reasons.reduce((s, r) => s + r.value, 0);
const blockCount = ip.data?.by_reason?.block ?? 0;
return (
<div className="space-y-4">
<PageHeader title="크롤 상태">
<Segmented ariaLabel="기간" options={RANGES} value={hours} onChange={setHours} />
</PageHeader>
{ip.isError && <ErrorNote error={ip.error} />}
{ip.data && (
blockCount === 0 ? (
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
<span aria-hidden>✓ </span>기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다
</p>
) : (
<p role="alert" className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
<span aria-hidden>⚠ </span>예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다.
요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget)
</p>
)
)}
<div className="grid gap-4 md:grid-cols-2">
<Card title="IP 세션 종료 사유" hint={`총 ${totalSessions}건`}>
{ip.isPending && <Loading />}
{ip.data && reasons.length === 0 && <Empty>기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다</Empty>}
{reasons.length > 0 && (
<div className="flex items-center gap-4">
{/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */}
<div className="relative shrink-0">
<PieChart width={150} height={150}>
<Pie data={reasons} dataKey="value" nameKey="label" innerRadius={46} outerRadius={70}
paddingAngle={2} cornerRadius={4} stroke="var(--color-surface)" strokeWidth={2} isAnimationActive={false}>
{reasons.map((r) => <Cell key={r.key} fill={r.color} />)}
</Pie>
{/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */}
</PieChart>
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="text-center">
<div className="tnum text-[20px] font-bold leading-none">{totalSessions}</div>
<div className="mt-0.5 text-[10px] font-medium text-ink-400">세션</div>
</div>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1.5 text-[12px]">
{reasons.map((r) => (
<li key={r.key} className="flex items-baseline gap-2">
<span className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ background: r.color }} aria-hidden />
<span className="font-semibold">{r.label}</span>
<span className="tnum ml-auto shrink-0 font-bold">{r.value}</span>
</li>
))}
</ul>
</div>
)}
{reasons.length > 0 && (
<p className="mt-2 text-[11px] leading-relaxed text-ink-400">
초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호.
</p>
)}
</Card>
<Card fill title="세션당 요청 수 분포" hint="예산 튜닝 근거">
{ip.isPending && <Loading />}
{ip.data && ip.data.histogram.length === 0 && <div className="grid h-full place-items-center"><Empty>데이터 없음</Empty></div>}
{ip.data && ip.data.histogram.length > 0 && (
<>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ip.data.histogram} margin={{ top: 14, right: 12, bottom: 0, left: -22 }}>
<defs>
<linearGradient id="histBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-cost-ai)" stopOpacity={0.95} />
<stop offset="100%" stopColor="var(--color-cost-ai)" stopOpacity={0.55} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="requests" {...xAxisProps} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} />
<Bar dataKey="count" fill="url(#histBar)" radius={[5, 5, 0, 0]} maxBarSize={36} isAnimationActive={false} />
{ip.data.block_min_requests != null && (
<ReferenceLine x={ip.data.block_min_requests} stroke="var(--color-dead-600)" strokeDasharray="4 3"
label={{ value: `차단 시작 ${ip.data.block_min_requests}회`, position: "top", fill: "var(--color-dead-600)", fontSize: 11, fontWeight: 600 }} />
)}
</BarChart>
</ResponsiveContainer>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-ink-400">
X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전.
</p>
</>
)}
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card fill title="차단 발생 추이" hint="시간대별">
{bot.isPending && <Loading />}
{bot.isError && <ErrorNote error={bot.error} />}
{bot.data && bot.data.hourly.length === 0 && <div className="grid h-full place-items-center"><Empty>기간 내 차단 없음 <span aria-hidden>🎉</span></Empty></div>}
{bot.data && bot.data.hourly.length > 0 && (
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={bot.data.hourly.map((h) => ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}>
<defs>
<linearGradient id="blockBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-dead-600)" stopOpacity={0.95} />
<stop offset="100%" stopColor="var(--color-dead-600)" stopOpacity={0.55} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={40} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} cursor={barCursor} formatter={(v: number) => [`${v}건`, "차단"]} />
<Bar dataKey="count" fill="url(#blockBar)" radius={[5, 5, 0, 0]} maxBarSize={28} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card>
<Card title="최근 차단 이력" hint="최근 50건">
{bot.data && bot.data.items.length === 0 && <Empty>기간 내 차단 없음</Empty>}
{bot.data && bot.data.items.length > 0 && (
<ScrollTable height={240} dense columns={BOT_COLS}>
{bot.data.items.map((b, i) => (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{hhmm(b.created_at)}</td>
<td className="max-w-32 truncate py-1.5 pr-2">{b.query || "—"}</td>
<td className="tnum py-1.5 pr-2 text-right">{b.ip_request_no ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{b.proxy_port ?? "—"}</td>
<td className="max-w-40 truncate py-1.5 text-[11px] text-ink-500">{b.marker}</td>
</tr>
))}
</ScrollTable>
)}
</Card>
</div>
<Card title="최근 IP 세션" hint="최근 50건">
{ip.data && ip.data.sessions.length === 0 && <Empty>세션 없음</Empty>}
{ip.data && ip.data.sessions.length > 0 && (
<ScrollTable height={320} dense columns={SESSION_COLS}>
{ip.data.sessions.map((s, i) => {
const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" };
return (
<tr key={i} className="border-b border-line-100">
<td className="tnum py-1.5 pr-2 text-ink-500">{dateShort(s.created_at)}</td>
<td className="py-1.5 pr-2">{s.source}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.proxy_port ?? "—"}</td>
<td className="tnum py-1.5 pr-2 text-right font-semibold">{s.requests}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{s.ok_count}/{s.blocked_count}</td>
<td className="tnum py-1.5 pr-2 text-right text-ink-500">{durationSec(s.elapsed_sec)}</td>
<td className="py-1.5">
<span className="inline-flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: r.color }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: r.color }} aria-hidden />
{r.label}
</span>
</td>
</tr>
);
})}
</ScrollTable>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,106 @@
/** 대시보드 — 지금 시스템이 건강한가. /ops 5초 폴링 + 임계 배너 + 큐 추이(세션 내 누적). */
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import {
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get } from "../api/client";
import type { OpsSnapshot } from "../api/types";
import { Card, ErrorNote, Legend, PageHeader, StatTile } from "../components/ui";
import { gridProps, tooltipProps, xAxisProps, yAxisProps } from "../lib/chart";
import { durationSec, usd } from "../lib/format";
// 알림 임계 기본값 미러 — 서버 [AlertConfig] 와 동일 기본(서버가 조정했으면 다를 수 있음, 참고용 배너).
const TH = { dead_1h: 20, blocks_1h: 80, queue_lag_sec: 300, pool_pct: 90, deadline_1h: 5, cost_1h_usd: 1.0 };
interface TrendPoint { t: string; pending: number; running: number }
export default function Dashboard() {
const trend = useRef<TrendPoint[]>([]);
const ops = useQuery({
queryKey: ["ops"],
queryFn: () => get<OpsSnapshot>("/v1/lps/ops"),
refetchInterval: 5_000,
});
if (ops.data) {
const now = new Date().toLocaleTimeString("ko-KR", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const last = trend.current[trend.current.length - 1];
if (!last || last.t !== now) {
trend.current = [...trend.current, { t: now, pending: ops.data.pending, running: ops.data.running }].slice(-120);
}
}
const d = ops.data;
const alerts: { key: string; msg: string }[] = [];
if (d) {
if (d.dead_1h >= TH.dead_1h) alerts.push({ key: "dead", msg: `최근 1시간 실패(DEAD) ${d.dead_1h}건 — 검색 실패가 쌓이고 있습니다` });
if (d.blocks_1h >= TH.blocks_1h) alerts.push({ key: "blocks", msg: `최근 1시간 차단 ${d.blocks_1h}건 — IP 평판 악화 신호` });
if (d.oldest_pending_sec >= TH.queue_lag_sec) alerts.push({ key: "lag", msg: `큐 지연 ${durationSec(d.oldest_pending_sec)} — 워커가 처리를 못 따라갑니다` });
if (d.stuck_running > 0) alerts.push({ key: "stuck", msg: `멈춘 작업 ${d.stuck_running}건 — 워커 사망/행 흔적` });
if (d.pool_pct >= TH.pool_pct) alerts.push({ key: "pool", msg: `DB 커넥션 풀 ${d.pool_pct}% 포화` });
if (d.deadline_1h >= TH.deadline_1h) alerts.push({ key: "deadline", msg: `최근 1시간 데드라인 강제종료 ${d.deadline_1h}건 — 크롤 행 반복 신호` });
if (d.cost_1h_usd >= TH.cost_1h_usd) alerts.push({ key: "cost", msg: `최근 1시간 검색원가 ${usd(d.cost_1h_usd, 2)} — 비용 급증 점검` });
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="대시보드" />
{ops.isError && <ErrorNote error={ops.error} />}
{alerts.map((a) => (
<p key={a.key} role="alert"
className="rounded-lg border border-dead-600/20 bg-dead-50 px-3 py-2 text-[13px] font-semibold text-dead-600">
<span aria-hidden>⚠ </span>{a.msg}
</p>
))}
{d && alerts.length === 0 && (
<p className="rounded-lg bg-ok-50 px-3 py-2 text-[13px] font-semibold text-ok-600">
<span aria-hidden>✓ </span>모든 지표가 임계 안에 있습니다
</p>
)}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile label="대기" value={d?.pending ?? "—"} tone="warn" sub="검색 순서를 기다리는 작업" />
<StatTile label="처리중" value={d?.running ?? "—"} tone="run" sub="워커가 검색 중" />
<StatTile label="완료" value={d?.done ?? "—"} tone="ok" sub="누적" />
<StatTile label="실패 (DEAD)" value={d?.dead ?? "—"} tone={d && d.dead > 0 ? "dead" : "default"} sub={`최근 1h ${d?.dead_1h ?? "—"}건`} />
<StatTile label="큐 지연" value={d ? durationSec(d.oldest_pending_sec) : "—"} sub="가장 오래 기다린 작업" />
<StatTile label="차단 (1h)" value={d?.blocks_1h ?? "—"} tone={d && d.blocks_1h > 0 ? "warn" : "default"} sub="쿠팡 봇 감지" />
<StatTile label="검색원가 (1h)" value={d ? usd(d.cost_1h_usd, 2) : "—"} sub="AI + 프록시 대역폭" />
<StatTile label="DB 풀" value={d ? `${d.pool_pct}%` : "—"} sub={d ? `${d.pool_checked_out}/${d.pool_capacity} 사용 중` : undefined} />
</div>
<Card fill className="flex-1" title="큐 추이" hint="5초 간격 · 이 화면을 열어둔 동안 누적">
<div className="min-h-0 flex-1">
{trend.current.length < 2 ? (
<div className="grid h-full place-items-center">
<p className="text-[12px] text-ink-400">수집 중… 잠시 후 그래프가 나타납니다</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trend.current} margin={{ top: 6, right: 12, bottom: 0, left: -18 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="t" {...xAxisProps} minTickGap={48} />
<YAxis allowDecimals={false} {...yAxisProps} />
<Tooltip {...tooltipProps} formatter={(v: number, name: string) => [v, name === "pending" ? "대기" : "처리중"]} />
<Line type="monotone" dataKey="pending" name="대기" stroke="var(--color-warn-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="running" name="처리중" stroke="var(--color-run-600)" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<Legend items={[
{ label: "대기", color: "var(--color-warn-600)" },
{ label: "처리중", color: "var(--color-run-600)" },
]} />
</Card>
<p className="shrink-0 text-[11px] text-ink-400">
임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음).
전체 룰은 lps/docs/operations.md 참고.
</p>
</div>
);
}

View File

@ -0,0 +1,200 @@
/** 작업 큐 — 잡 목록(필터·검색·페이지네이션) 표 + 행별 상세/JSON 모달 + DEAD 재큐. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { get, post } from "../api/client";
import type { JobDetailRes, JobListItem, JobListRes, JobStatusName, RequeueRes } from "../api/types";
import { Button, Card, Empty, ErrorNote, Loading, Modal, PageHeader, ScrollTable, SearchForm, Segmented, StatusBadge } from "../components/ui";
import { dateShort, usd, won } from "../lib/format";
type DetailView = "detail" | "json";
const COLS = [
{ key: "product", label: "상품" },
{ key: "status", label: "상태" },
{ key: "outcome", label: "결과" },
{ key: "price", label: "최저가", align: "right" },
{ key: "cost", label: "원가", align: "right" },
{ key: "time", label: "요청 시각" },
{ key: "actions", label: "", align: "right" },
] as const;
const PAGE = 20;
const TABS: { v: JobStatusName | ""; label: string }[] = [
{ v: "", label: "전체" },
{ v: "PENDING", label: "대기" },
{ v: "RUNNING", label: "처리중" },
{ v: "DONE", label: "완료" },
{ v: "DEAD", label: "실패" },
];
export default function Jobs() {
const [status, setStatus] = useState<JobStatusName | "">("");
const [q, setQ] = useState("");
const [qInput, setQInput] = useState("");
const [page, setPage] = useState(0);
const [modalJob, setModalJob] = useState<JobListItem | null>(null);
const [modalView, setModalView] = useState<DetailView>("detail");
const open = (j: JobListItem, view: DetailView) => { setModalView(view); setModalJob(j); };
const params = new URLSearchParams({ limit: String(PAGE), offset: String(page * PAGE) });
if (status) params.set("status", status);
if (q) params.set("q", q);
const list = useQuery({
queryKey: ["jobs", status, q, page],
queryFn: () => get<JobListRes>(`/v1/lps/jobs?${params}`),
refetchInterval: 15_000,
});
const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE));
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="작업 큐" />
<div className="flex flex-wrap items-center gap-2">
<Segmented ariaLabel="상태 필터" options={TABS} value={status}
onChange={(v) => { setStatus(v); setPage(0); }} />
<button onClick={() => { const v = q === "TEST-" ? "" : "TEST-"; setQ(v); setQInput(v); setPage(0); }}
aria-pressed={q === "TEST-"}
className={`rounded-lg border px-3 py-1.5 text-[12px] font-semibold ${
q === "TEST-" ? "border-primary-600 bg-primary-50 text-primary-700" : "border-line-200 text-ink-500 hover:text-ink-700"
}`}>
테스트만
</button>
<SearchForm className="ml-auto" inputClassName="w-56" placeholder="상품코드·상품명 검색"
value={qInput} onChange={setQInput} onSubmit={() => { setQ(qInput.trim()); setPage(0); }} />
</div>
<Card fill className="min-h-0 flex-1" title={`작업 ${list.data?.total ?? "—"}건`} hint="15초마다 갱신">
{list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>조건에 맞는 작업이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<ScrollTable height="fill" columns={COLS}>
{list.data.items.map((j) => (
<tr key={j.job_id} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-2 pr-3">
<div className="font-semibold">{j.product_name || "—"}</div>
<div className="text-[11px] text-ink-400">{j.product_code}</div>
</td>
<td className="py-2 pr-3"><StatusBadge status={j.status} /></td>
<td className="py-2 pr-3">{j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"}</td>
<td className="tnum py-2 pr-3 text-right">{won(j.final_lowest)}</td>
<td className="tnum py-2 pr-3 text-right text-ink-500">{usd(j.cost_usd)}</td>
<td className="tnum py-2 pr-3 text-[12px] text-ink-500">{dateShort(j.created_at)}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" onClick={() => open(j, "detail")}>상세</Button>
<Button variant="outline" size="sm" onClick={() => open(j, "json")}>JSON</Button>
</div>
</td>
</tr>
))}
</ScrollTable>
)}
{pages > 1 && (
<nav aria-label="페이지" className="mt-3 flex shrink-0 items-center justify-end gap-2 text-[12px]">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>이전</Button>
<span className="tnum text-ink-500">{page + 1} / {pages}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= pages} onClick={() => setPage((p) => p + 1)}>다음</Button>
</nav>
)}
</Card>
{modalJob && (
<JobModal job={modalJob} view={modalView} onView={setModalView} onClose={() => setModalJob(null)} />
)}
</div>
);
}
// 행별 "상세"/"JSON" 버튼이 여는 모달. 상단 토글로 두 뷰 전환, JSON 뷰엔 복사 버튼.
function JobModal({ job, view, onView, onClose }: {
job: JobListItem; view: DetailView; onView: (v: DetailView) => void; onClose: () => void;
}) {
const qc = useQueryClient();
const detail = useQuery({
queryKey: ["job", job.job_id],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${job.job_id}`),
});
const requeue = useMutation({
mutationFn: () => post<RequeueRes>(`/v1/lps/jobs/${job.job_id}/requeue`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
});
const [copied, setCopied] = useState(false);
const out = detail.data?.output as Record<string, any> | undefined;
const jsonText = out ? JSON.stringify(out, null, 2) : "";
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return (
<Modal
title={<>상세 <span className="font-medium text-ink-400">— {job.product_name || job.product_code}</span></>}
onClose={onClose}
actions={
<>
<Segmented ariaLabel="보기 방식" size="sm" value={view}
options={[{ v: "detail", label: "상세" }, { v: "json", label: "JSON" }]} onChange={onView} />
{view === "json" && (
<Button variant="outline" size="sm" disabled={!out} onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>
)}
</>
}
>
{view === "detail" ? (
<>
<dl className="space-y-2 text-[13px]">
<Row k="작업 ID"><span className="tnum break-all text-[12px]">{job.job_id}</span></Row>
<Row k="상품">{job.product_name || "—"}{job.product_code && <span className="text-ink-400"> ({job.product_code})</span>}</Row>
<Row k="상태"><StatusBadge status={job.status} /> <span className="text-ink-400">시도 {job.attempts}/{job.max_attempts}</span></Row>
{job.last_error && <Row k="오류"><span className="break-all text-dead-600">{job.last_error}</span></Row>}
{detail.isPending && <Row k="결과"><span className="text-ink-400">불러오는 중…</span></Row>}
{out?.outcome && <Row k="결과">{out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}</Row>}
{out?.lowest && <Row k="최저가"><b>{won(out.lowest.price)}</b> <span className="text-ink-400">({out.lowest.source} · {out.lowest.mall_name || "—"})</span></Row>}
{out?.metrics?.cost && (
<Row k="검색 원가">
{usd(out.metrics.cost.total_usd)} <span className="text-ink-400">(AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)})</span>
</Row>
)}
{out?.metrics?.duration_ms != null && <Row k="소요">{(out.metrics.duration_ms / 1000).toFixed(1)}초</Row>}
</dl>
{job.status === "DEAD" && (
<div className="mt-4 border-t border-line-100 pt-3">
<Button size="lg" className="w-full" onClick={() => requeue.mutate()} disabled={requeue.isPending}>
{requeue.isPending ? "재시도 요청 중…" : "다시 검색하기 (재큐)"}
</Button>
{requeue.isError && <p className="mt-2 text-[12px] font-medium text-dead-600">{(requeue.error as Error).message}</p>}
{requeue.isSuccess && <p className="mt-2 text-[12px] font-medium text-ok-600">대기열에 다시 넣었습니다 — 워커가 곧 처리합니다</p>}
</div>
)}
</>
) : detail.isPending ? (
<Loading />
) : detail.isError ? (
<ErrorNote error={detail.error} />
) : out ? (
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
) : (
<Empty>이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패)</Empty>
)}
</Modal>
);
}
function Row({ k, children }: { k: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-16 shrink-0 text-[12px] font-semibold text-ink-400">{k}</dt>
<dd className="min-w-0 flex-1">{children}</dd>
</div>
);
}

View File

@ -0,0 +1,277 @@
/** 상품·가격 — 이력 상품 목록 → 가격 추이 3선(네이버·쿠팡·최종) + 몰별 가격 비교 + 재검색. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { get, post } from "../api/client";
import type { PriceHistoryRes, PricePoint, ProductItem, ProductListRes, SearchRes } from "../api/types";
import { Button, Card, Empty, ErrorNote, Legend, Loading, PageHeader, ScrollBox, ScrollTable, SearchForm, Segmented } from "../components/ui";
import { gridProps, xAxisProps, yAxisProps } from "../lib/chart";
import { dateShort, timeAgo, won } from "../lib/format";
// 시리즈 색은 엔터티 고정(네이버 초록은 CVD 분리 검증 통과 톤) — 순서·색 재배정 금지.
const SERIES = [
{ key: "naver", label: "네이버", color: "var(--color-naver)" },
{ key: "coupang", label: "쿠팡", color: "var(--color-coupang)" },
{ key: "final", label: "최종 최저가", color: "var(--color-final)" },
] as const;
// 커스텀 툴팁 — 둥근 카드·부드러운 그림자·토큰 색. 값 없는(못 찾은) 소스는 생략, 시리즈 순서 고정.
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: { dataKey?: string | number; value?: number; name?: string }[];
label?: string;
}) {
if (!active || !payload?.length) return null;
const byKey = new Map(payload.map((p) => [p.dataKey, p]));
return (
<div className="rounded-xl border border-line-200 bg-surface px-3 py-2 shadow-[0_8px_24px_rgba(16,24,40,.14)]">
<div className="mb-1.5 text-[11px] font-semibold text-ink-400">{label}</div>
<ul className="space-y-1">
{SERIES.map((s) => {
const p = byKey.get(s.key);
if (p?.value == null) return null;
return (
<li key={s.key} className="flex items-center gap-2 text-[12px]">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: s.color }} aria-hidden />
<span className="text-ink-500">{s.label}</span>
<span className="tnum ml-auto pl-6 font-bold text-ink-900">{won(p.value)}</span>
</li>
);
})}
</ul>
</div>
);
}
export default function Products() {
const [q, setQ] = useState("");
const [qInput, setQInput] = useState("");
const [selected, setSelected] = useState<ProductItem | null>(null);
const list = useQuery({
queryKey: ["products", q],
queryFn: () => get<ProductListRes>(`/v1/lps/products?limit=100${q ? `&q=${encodeURIComponent(q)}` : ""}`),
});
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<PageHeader title="상품·가격" />
<SearchForm placeholder="상품코드 검색" value={qInput} onChange={setQInput} onSubmit={() => setQ(qInput.trim())} />
<Card fill className="min-h-0 min-w-0 flex-1" title={`검색 이력 상품 ${list.data?.items.length ?? "—"}건`} hint="최근 검색순">
{list.isPending && <Loading />}
{list.isError && <ErrorNote error={list.error} />}
{list.data && list.data.items.length === 0 && <Empty>아직 검색된 상품이 없습니다</Empty>}
{list.data && list.data.items.length > 0 && (
<ScrollBox height="fill">
<ul className="divide-y divide-line-100">
{list.data.items.map((p) => (
<li key={p.product_code}>
<button onClick={() => setSelected(p)}
className={`block w-full border-l-2 px-3 py-2.5 text-left transition-colors ${
selected?.product_code === p.product_code
? "border-primary-600 bg-primary-50/70"
: "border-transparent hover:bg-surface-2"
}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold">{p.display_name || p.product_code}</span>
<span className="tnum shrink-0 text-[13px] font-bold">{won(p.final_lowest)}</span>
</div>
<div className="mt-0.5 flex justify-between text-[11px] text-ink-400">
<span>{p.product_code} · 검색 {p.searches}회</span>
<span>{timeAgo(p.triggered_at)}{p.outcome === "not_found" ? " · 못 찾음" : ""}</span>
</div>
</button>
</li>
))}
</ul>
</ScrollBox>
)}
</Card>
<ProductDetail product={selected} />
</div>
);
}
function ProductDetail({ product }: { product: ProductItem | null }) {
const qc = useQueryClient();
// 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백.
const [selectedTs, setSelectedTs] = useState<string | null>(null);
const history = useQuery({
queryKey: ["history", product?.product_code],
queryFn: () => get<PriceHistoryRes>(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`),
enabled: !!product,
});
const research = useMutation({
mutationFn: () => post<SearchRes>("/v1/lps/search", {
data: [{ product_code: product!.product_code, product_name: product!.display_name || product!.product_code, job_type: "manual" }],
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }),
});
if (!product) return (
<Card fill className="min-h-0 flex-[2]" title="가격 추이">
<div className="grid h-full place-items-center"><Empty>목록에서 상품을 선택하세요</Empty></div>
</Card>
);
const points = (history.data?.points ?? []).map((p) => ({ ...p, x: dateShort(p.triggered_at) }));
const latest = history.data?.points.at(-1);
// 클릭한 시점의 데이터 포인트(없으면 최신). 몰별 비교 표에 이 시점의 by_mall 을 보여준다.
const activePoint = points.find((p) => p.triggered_at === selectedTs) ?? latest;
return (
// xl 이상: 왼쪽 그래프 + 오른쪽 몰별 표 가로 배치. 그 이하: 세로 스택(스크롤).
// min-w-0: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지.
<div className="flex min-h-0 min-w-0 flex-[2] flex-col gap-4 max-xl:overflow-y-auto xl:flex-row">
<Card fill className="min-w-0 flex-1"
title={<>가격 추이 — {product.display_name || product.product_code}</>}
hint={
<Button size="sm" onClick={() => research.mutate()} disabled={research.isPending}>
{research.isPending ? "요청 중…" : "지금 다시 검색"}
</Button>
}>
{research.isSuccess && (
<p className="mb-2 shrink-0 rounded-lg bg-ok-50 px-3 py-1.5 text-[12px] font-medium text-ok-600">
{research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"}
</p>
)}
{research.isError && <div className="mb-2 shrink-0"><ErrorNote error={research.error} /></div>}
<div className="min-h-[240px] flex-1">
{history.isPending ? (
<div className="grid h-full place-items-center"><Loading /></div>
) : history.isError ? (
<ErrorNote error={history.error} />
) : points.length === 0 ? (
<div className="grid h-full place-items-center"><Empty>이력이 없습니다</Empty></div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={points} margin={{ top: 10, right: 16, bottom: 0, left: 0 }} style={{ cursor: "pointer" }}
onClick={(state) => {
const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at;
if (ts) setSelectedTs(ts);
}}>
<defs>
<linearGradient id="finalArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-final)" stopOpacity={0.14} />
<stop offset="100%" stopColor="var(--color-final)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="x" {...xAxisProps} minTickGap={56} tickMargin={8} />
<YAxis {...yAxisProps} width={64} tickMargin={4} tickFormatter={(v: number) => v.toLocaleString("ko-KR")} domain={[0, "auto"]} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} />
{activePoint && (
<ReferenceLine x={dateShort(activePoint.triggered_at)} stroke="var(--color-ink-400)" strokeDasharray="3 3" />
)}
{SERIES.filter((s) => s.key !== "final").map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2.5}
strokeLinecap="round" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: s.color, stroke: "var(--color-surface)", strokeWidth: 2 }} />
))}
<Area type="monotone" dataKey="final" name="최종 최저가" stroke="var(--color-final)" strokeWidth={2.5}
strokeLinecap="round" fill="url(#finalArea)" connectNulls isAnimationActive={false}
dot={{ r: 3, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 1.5 }}
activeDot={{ r: 5, fill: "var(--color-final)", stroke: "var(--color-surface)", strokeWidth: 2 }} />
</ComposedChart>
</ResponsiveContainer>
)}
</div>
{points.length > 0 && (
<Legend items={SERIES.map((s) => ({ label: s.label, color: s.color }))}
note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" />
)}
</Card>
{activePoint ? (
<MallCompare point={activePoint} />
) : (
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0" title="몰별 가격 비교">
<div className="grid h-full place-items-center"><Empty>최근 검색 데이터가 없습니다</Empty></div>
</Card>
)}
</div>
);
}
// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지.
const TOP_OPTIONS: { v: number | "all"; label: string }[] = [
{ v: 3, label: "3" }, { v: 5, label: "5" }, { v: 10, label: "10" }, { v: "all", label: "전체" },
];
const MALL_COLS = [
{ key: "rank", label: "#", width: "2.25rem" },
{ key: "mall", label: "쇼핑몰" },
{ key: "price", label: "가격", align: "right", width: "5rem" },
] as const;
// 소스(발견 채널) 색·라벨 — 시리즈 색 재사용(엔터티 고정).
const srcColor = (s?: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
const srcLabel = (s?: string) => s === "naver" ? "네이버" : s === "coupang" ? "쿠팡" : (s || "기타");
function MallCompare({ point }: { point: PricePoint }) {
const [topN, setTopN] = useState<number | "all">(5);
const malls = (point.by_mall ?? [])
.filter((m) => m.price != null)
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
const shown = topN === "all" ? malls : malls.slice(0, topN);
const cheapest = malls[0]?.price ?? 0;
return (
<Card fill className="min-w-0 xl:w-[320px] xl:shrink-0"
title="몰별 가격 비교"
hint={<Segmented ariaLabel="상위 N개" size="sm" value={topN} options={TOP_OPTIONS} onChange={setTopN} />}>
<p className="mb-1.5 shrink-0 text-[11px] text-ink-400">
<span className="tnum font-semibold text-ink-500">{dateShort(point.triggered_at)}</span> 기준 · 그래프의 시점을 클릭해 이동
</p>
{shown.length === 0 ? (
<div className="grid h-full place-items-center"><Empty>몰별 데이터 없음</Empty></div>
) : (
<>
<ScrollTable height="fill" dense fixed columns={MALL_COLS}>
{shown.map((m, i) => {
const price = m.price ?? 0;
const delta = price - cheapest;
const pct = cheapest > 0 ? (delta / cheapest) * 100 : 0;
const src = srcColor(m.source);
return (
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-1.5 pr-1 align-top">
<span className={`tnum grid h-4 w-4 place-items-center rounded-full text-[10px] font-bold ${i === 0 ? "bg-ok-50 text-ok-600" : "bg-surface-2 text-ink-500"}`}>{i + 1}</span>
</td>
<td className="min-w-0 py-1.5 pr-2 align-top">
<div className="flex items-center gap-1">
<span className="shrink-0 rounded px-1 text-[9px] font-bold leading-normal" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{srcLabel(m.source)}</span>
<span className="truncate text-[11px] font-semibold">{m.mall_name || m.source}</span>
{i === 0 && <span className="shrink-0 rounded bg-ok-50 px-1 text-[9px] font-bold text-ok-600">최저</span>}
</div>
<div className="truncate text-[10px] leading-tight text-ink-400">{m.name}</div>
</td>
<td className="py-1.5 text-right align-top">
<div className="tnum whitespace-nowrap text-[12px] font-bold leading-tight">{won(price)}</div>
<div className="tnum mt-0.5 flex items-center justify-end gap-0.5 whitespace-nowrap text-[10px] text-ink-400">
<span>{delta === 0 ? "최저" : `+${pct.toFixed(1)}%`}</span>
{m.detail_url && (
<a href={m.detail_url} target="_blank" rel="noreferrer"
className="font-bold text-primary-600 hover:underline" aria-label="상품 페이지 열기 (새 탭)">↗</a>
)}
</div>
</td>
</tr>
);
})}
</ScrollTable>
{topN !== "all" && malls.length > shown.length && (
<p className="mt-2 shrink-0 text-[11px] text-ink-400">몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기</p>
)}
</>
)}
</Card>
);
}

View File

@ -0,0 +1,50 @@
/** 설정 — API base URL·guard 키(localStorage). prod 에서 X-API-Key 헤더로 자동 첨부. */
import { useState } from "react";
import { settings } from "../api/client";
import { Button, Card, PageHeader } from "../components/ui";
export default function Settings() {
const [baseUrl, setBaseUrl] = useState(settings.baseUrl());
const [apiKey, setApiKey] = useState(settings.apiKey());
const [saved, setSaved] = useState(false);
return (
<div className="max-w-xl space-y-4">
<PageHeader title="설정" />
<Card title="API 연결">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
settings.save(baseUrl.trim(), apiKey.trim());
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}}
>
<div>
<label htmlFor="base" className="mb-1 block text-[12px] font-semibold text-ink-700">API 주소</label>
<input id="base" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
placeholder="비우면 현재 주소 기준 (개발: vite 프록시 → :9600)"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">예: https://lps.example.com — dev 서버를 직접 보려면 해당 주소 입력</p>
</div>
<div>
<label htmlFor="key" className="mb-1 block text-[12px] font-semibold text-ink-700">API 키 (X-API-Key)</label>
<input id="key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)}
placeholder="개발(개방 모드)은 비워둠 — prod 만 필요"
autoComplete="off"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
<p className="mt-1 text-[11px] text-ink-400">서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.</p>
</div>
<Button type="submit" size="lg">저장</Button>
{saved && <span className="ml-2 text-[12px] font-medium text-ok-600">저장했습니다</span>}
</form>
</Card>
<p className="text-[11px] leading-relaxed text-ink-400">
지표 임계·요청 예산 등 서버 동작 설정은 이 화면에서 바꾸지 않습니다 — 설정 소스는 서버의
config.&lt;env&gt;.toml 하나이며(2026-07-13 협의), 변경은 toml 수정 + 재시작으로 합니다.
</p>
</div>
);
}

View File

@ -0,0 +1,426 @@
/** 테스트 검색 — 임의 상품을 실제 파이프라인(큐→워커→네이버·쿠팡→AI 매칭)에 흘려보내고
* 단계별 퍼널·소스별 결과·매칭 상품·검색 원가를 시각화한다. 셀렉터·프록시·매칭 회귀 점검용.
*
* product_code 는 TEST-<시각> 으로 자동 생성 — 프로덕션 데이터(잡·이력·비용통계)와 필터로
* 구분되고, negodata 는 UUID 만 매핑하므로 연동에 영향 없다. 자격증명은 서버에만 있으며
* 여기 결과엔 집계된 프록시 사용량(바이트·$)만 표시된다. */
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { get, post } from "../api/client";
import type { JobDetailRes, JobOutput, SearchRes } from "../api/types";
import { Button, Card, Empty, ErrorNote, Modal, PageHeader, ScrollTable } from "../components/ui";
import { usd, won } from "../lib/format";
const TOP_COLS = [
{ key: "source", label: "소스" },
{ key: "name", label: "상품명" },
{ key: "price", label: "가격", align: "right" },
{ key: "ship", label: "배송", align: "right" },
{ key: "link", label: "" },
] as const;
const SOURCE_LABEL: Record<string, string> = { naver: "네이버", coupang: "쿠팡", gmarket: "G마켓", auction: "옥션", st11: "11번가" };
const sourceLabel = (s: string) => SOURCE_LABEL[s] ?? s;
// 소스(발견 채널) 색 — 시리즈 색 재사용(엔터티 고정). 상품·가격 몰별 비교와 동일 언어.
const srcColor = (s: string) => s === "naver" ? "var(--color-naver)" : s === "coupang" ? "var(--color-coupang)" : "var(--color-ink-400)";
// 배송 표기 — 쿠팡 로켓 계열(로켓배송·판매자로켓·로켓프레시…) / 무료 / 유료 / 미상.
function shipInfo(fee?: number | null, type?: string | null): { text: string; tone: "rocket" | "free" | "paid" | "none" } {
const t = (type ?? "").toLowerCase();
if (t.startsWith("rocket")) {
const label = t.includes("fresh") ? "로켓프레시" : t.includes("wow") ? "로켓와우"
: t.includes("global") ? "로켓직구" : (t.includes("merchant") || t.includes("seller")) ? "판매자로켓" : "로켓배송";
return { text: label, tone: "rocket" };
}
if (t === "free" || fee === 0) return { text: "무료", tone: "free" };
if (fee != null && fee > 0) return { text: `+${fee.toLocaleString("ko-KR")}원`, tone: "paid" };
return { text: "—", tone: "none" };
}
// 소스·몰명 중복 제거 — 몰명이 없거나 소스 라벨과 같으면 소스만.
const sourceMall = (source: string, mall?: string) => {
const sl = sourceLabel(source);
return mall && mall !== sl ? `${sl} · ${mall}` : sl;
};
// 파이프라인 단계 한글 라벨 — result.stages 의 stage 키 매핑.
const STAGE_LABEL: Record<string, string> = {
filter: "가격/몰 필터",
outlier: "이상치 제거",
ai_match: "AI 같은상품 판정",
top_n: "최저가 top-N",
};
function makeTestCode() {
// Date.now 로 사람이 읽을 수 있는 시각 코드(중복 방지 + 목록 필터 구분).
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `TEST-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
interface FormState {
product_name: string;
model: string;
specification: string;
company: string;
price: string;
}
export default function TestSearch() {
const [form, setForm] = useState<FormState>({ product_name: "", model: "", specification: "", company: "", price: "" });
const [jobId, setJobId] = useState<string | null>(null);
const [testCode, setTestCode] = useState<string | null>(null);
const startedAt = useRef<number>(0);
const submit = useMutation({
mutationFn: async () => {
const code = makeTestCode();
const res = await post<SearchRes>("/v1/lps/search", {
data: [{
product_code: code,
product_name: form.product_name.trim(),
job_type: "manual",
model: form.model.trim(),
specification: form.specification.trim(),
company: form.company.trim(),
price: form.price.trim(),
}],
});
return { res, code };
},
onSuccess: ({ res, code }) => {
const entry = res.items[0];
setTestCode(code);
startedAt.current = Date.now();
setJobId(entry?.job_id ?? null);
},
});
// 잡 완료까지 2초 폴링. DONE/DEAD 면 멈춘다.
const job = useQuery({
queryKey: ["test-job", jobId],
queryFn: () => get<JobDetailRes>(`/v1/lps/jobs/${jobId}`),
enabled: !!jobId,
refetchInterval: (q) => {
const st = q.state.data?.status;
return st === "DONE" || st === "DEAD" ? false : 2000;
},
});
const status = job.data?.status;
const output = job.data?.output;
const done = status === "DONE" || status === "DEAD";
// 진행 중엔 1초마다 경과 시간을 갱신(폴링 간격과 무관하게 매끄럽게), 완료되면 정지.
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!jobId || done) return;
const tick = () => setElapsed(Math.floor((Date.now() - startedAt.current) / 1000));
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [jobId, done]);
const pendingTooLong = status === "PENDING" && elapsed > 8;
const reset = () => {
setJobId(null);
setTestCode(null);
submit.reset();
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<PageHeader title="테스트 검색" />
<p className="text-[11px] text-ink-400">
실제 크롤이라 <b className="font-semibold text-warn-700">프록시 비용 발생</b> · 비용 통계 집계 (상품코드 <code className="rounded bg-surface-2 px-1">TEST-</code>)
</p>
</div>
<div className="grid gap-3 lg:grid-cols-[360px_1fr]">
{/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */}
<div className="space-y-3 lg:sticky lg:top-0 lg:self-start">
<Card compact title="검색 조건">
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }}
>
<Field id="pn" label="상품명" required value={form.product_name}
onChange={(v) => setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" />
<div className="grid grid-cols-2 gap-3">
<Field id="model" label="모델명" value={form.model} onChange={(v) => setForm((f) => ({ ...f, model: v }))} placeholder="선택" />
<Field id="company" label="제조사" value={form.company} onChange={(v) => setForm((f) => ({ ...f, company: v }))} placeholder="선택" />
</div>
<Field id="spec" label="규격" value={form.specification}
onChange={(v) => setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" />
<Field id="price" label="현재가(원)" value={form.price} inputMode="numeric"
onChange={(v) => setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" />
<div className="flex gap-2 pt-1">
{/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */}
<button type="submit" disabled={!form.product_name.trim() || submit.isPending || !!jobId}
className="flex-1 rounded-lg bg-warn-700 px-3 py-2 text-[13px] font-semibold text-white hover:bg-warn-600 disabled:opacity-50">
{submit.isPending ? "제출 중…" : jobId ? "실행됨 — 초기화 후 재검색" : "검색 실행"}
</button>
{jobId && (
<Button type="button" variant="outline" size="lg" onClick={reset}>초기화</Button>
)}
</div>
{submit.isError && <ErrorNote error={submit.error} />}
</form>
</Card>
{jobId && (
<Card compact title="진행 상태" hint={testCode ?? undefined}>
<Progress status={status} elapsed={elapsed} />
{job.isError && <div className="mt-2"><ErrorNote error={job.error} /></div>}
{pendingTooLong && (
<p role="alert" className="mt-3 rounded-lg bg-warn-50 px-3 py-2 text-[12px] font-medium text-warn-700">
{elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (<code>./run_local_worker.sh</code>). 워커가 없으면 크롤이 시작되지 않습니다.
</p>
)}
{status === "DEAD" && (
<p role="alert" className="mt-3 rounded-lg bg-dead-50 px-3 py-2 text-[12px] font-medium text-dead-600">
검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"}
</p>
)}
</Card>
)}
</div>
{/* 오른쪽: 결과 */}
<div className="space-y-3">
{status === "DONE" && output ? (
<ResultView output={output} />
) : (
<Card compact title="결과">
<Empty>
{!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요"
: status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요"
: "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"}
</Empty>
</Card>
)}
</div>
</div>
</div>
);
}
// ── 진행 스텝퍼 ──────────────────────────────────────────────
function Progress({ status, elapsed }: { status?: string; elapsed: number }) {
const steps = [
{ key: "PENDING", label: "대기" },
{ key: "RUNNING", label: "크롤·판정 중" },
{ key: "DONE", label: "완료" },
];
const order = ["PENDING", "RUNNING", "DONE"];
const curIdx = status === "DEAD" ? 1 : Math.max(0, order.indexOf(status ?? "PENDING"));
return (
<div className="flex items-center gap-2">
{steps.map((s, i) => {
const done = i < curIdx || status === "DONE";
const active = i === curIdx && status !== "DONE";
return (
<div key={s.key} className="flex items-center gap-2">
<span className={`flex h-6 items-center rounded-full px-2.5 text-[12px] font-semibold ${
done ? "bg-ok-50 text-ok-600" : active ? "bg-run-50 text-run-600" : "bg-surface-2 text-ink-400"
}`}>
{active && <span className="mr-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-run-600" aria-hidden />}
{s.label}
</span>
{i < steps.length - 1 && <span className="h-px w-4 bg-line-200" aria-hidden />}
</div>
);
})}
<span className="ml-auto tnum text-[12px] text-ink-400">{elapsed}초</span>
</div>
);
}
// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ──────
function ResultView({ output }: { output: JobOutput }) {
const found = output.outcome === "found";
const m = output.metrics;
const [showJson, setShowJson] = useState(false);
const [copied, setCopied] = useState(false);
const jsonText = JSON.stringify(output, null, 2);
const copyJson = async () => {
try {
await navigator.clipboard.writeText(jsonText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* 클립보드 권한 없음 — 무시 */ }
};
return (
<>
<Card compact className={found ? "border-l-[3px] border-l-primary-500" : ""}
title="판정" hint={output.round ? `${output.round} 라운드에서 매칭` : output.cached ? "네거티브 캐시" : undefined}>
{found ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="rounded-full bg-ok-50 px-2 py-0.5 text-[12px] font-semibold text-ok-600">같은 상품 찾음</span>
{output.lowest && (
<span className="text-[15px] font-bold">
최저가 {won(output.lowest.price)}
<span className="ml-1.5 text-[12px] font-medium text-ink-400">
({sourceMall(output.lowest.source, output.lowest.mall_name)})
</span>
</span>
)}
</div>
) : (
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[12px] font-semibold text-ink-500">
같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""}
</span>
)}
{output.query && <p className="mt-2 text-[12px] text-ink-500">최종 검색어: <b>{output.query}</b></p>}
</Card>
{output.stages && output.stages.length > 0 && (
<Card compact title="파이프라인 퍼널" hint="각 단계가 몇 건을 남겼나">
<FunnelChart stages={output.stages} totalFound={output.total_found} />
</Card>
)}
<div className="grid gap-3 md:grid-cols-2">
{output.sources && Object.keys(output.sources).length > 0 && (
<Card compact title="소스별 수집" hint="검색어 매칭 전 원본 건수">
{output.query && (
<p className="mb-2 rounded-md bg-surface-2 px-2 py-1 text-[11px] text-ink-500">
각 사이트 검색어 <span className="font-semibold text-ink-700">"{output.query}"</span>
</p>
)}
<ul className="space-y-1 text-[12px]">
{Object.entries(output.sources).map(([s, v]) => (
<li key={s} className="flex items-baseline justify-between gap-2">
<span className="font-semibold">{sourceLabel(s)}</span>
<span className="tnum text-right">
{v.error ? (
<span className="font-semibold text-dead-600">실패 · {v.error.split(":")[0]}</span>
) : (
<>{v.count ?? 0}건</>
)}
{m?.source_ms?.[s] != null && <span className="ml-1 text-ink-400">· {(m.source_ms[s] / 1000).toFixed(1)}초</span>}
</span>
</li>
))}
</ul>
</Card>
)}
{m && (
<Card compact title="검색 원가" hint={m.duration_ms != null ? `${(m.duration_ms / 1000).toFixed(1)}초 소요` : undefined}>
<ul className="space-y-1 text-[12px]">
<li className="flex justify-between"><span className="text-ink-500">총 비용</span><span className="tnum font-bold">{usd(m.cost?.total_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">AI 판정·검색어</span><span className="tnum">{usd(m.cost?.ai_usd)}</span></li>
<li className="flex justify-between"><span className="text-ink-500">프록시 대역폭</span><span className="tnum">{usd(m.cost?.proxy_usd)}</span></li>
{m.crawl && (
<li className="flex justify-between border-t border-line-100 pt-1.5">
<span className="text-ink-500">프록시 전송량</span>
<span className="tnum text-ink-400">{(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회</span>
</li>
)}
</ul>
</Card>
)}
</div>
{output.top && output.top.length > 0 && (
<Card compact title="매칭된 상품" hint={`최저가순 상위 ${output.top.length}건`}>
<ScrollTable height="auto" dense columns={TOP_COLS}>
{output.top.map((p, i) => {
const src = srcColor(p.source);
const ship = shipInfo(p.shipping_fee, p.shipping_type);
return (
<tr key={i} className="border-b border-line-100 hover:bg-surface-2">
<td className="py-1.5 pr-2 align-top">
<span className="inline-block whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold" style={{ color: src, background: `color-mix(in srgb, ${src} 14%, transparent)` }}>{sourceLabel(p.source)}</span>
</td>
<td className="max-w-md truncate py-1.5 pr-2 align-top">{p.name}</td>
<td className="py-1.5 pr-2 text-right align-top whitespace-nowrap">
<span className={`tnum font-bold ${i === 0 ? "text-ok-600" : ""}`}>{won(p.price)}</span>
{i === 0 && <span className="ml-1 whitespace-nowrap rounded bg-ok-50 px-1 text-[10px] font-bold text-ok-600">최저</span>}
</td>
<td className="py-1.5 pr-2 text-right align-top">
{ship.tone === "rocket" ? (
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded px-1.5 py-px text-[10px] font-bold text-run-600"
style={{ background: "color-mix(in srgb, var(--color-run-600) 12%, transparent)" }}>🚀 {ship.text}</span>
) : (
<span className={`tnum whitespace-nowrap text-[11px] ${ship.tone === "free" ? "font-semibold text-ok-600" : "text-ink-400"}`}>{ship.text}</span>
)}
</td>
<td className="py-1.5 text-right align-top">
{p.detail_url && (
<a href={p.detail_url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-line-200 px-2 py-0.5 text-[11px] font-semibold text-ink-700 transition-colors hover:bg-line-200 hover:text-ink-900">사이트 →</a>
)}
</td>
</tr>
);
})}
</ScrollTable>
</Card>
)}
<div>
<Button variant="outline" size="sm" onClick={() => setShowJson(true)}>원본 결과(JSON) 보기</Button>
</div>
{showJson && (
<Modal title="원본 결과 (JSON)" onClose={() => setShowJson(false)}
actions={<Button variant="outline" size="sm" onClick={copyJson}>{copied ? "복사됨 ✓" : "복사"}</Button>}>
<pre className="w-max min-w-full rounded-lg bg-surface-2 p-3 text-[11px] leading-relaxed">{jsonText}</pre>
</Modal>
)}
</>
);
}
// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ──────────
function FunnelChart({ stages, totalFound }: { stages: { stage: string; in: number; out: number }[]; totalFound?: number }) {
const rows = [
...(totalFound != null ? [{ label: "수집", value: totalFound }] : []),
...stages.map((s) => ({ label: STAGE_LABEL[s.stage] ?? s.stage, value: s.out })),
];
const max = Math.max(1, ...rows.map((r) => r.value));
return (
<ul className="space-y-1">
{rows.map((r, i) => {
const prev = i > 0 ? rows[i - 1].value : null;
const drop = prev != null ? r.value - prev : null; // 이전 단계 대비 증감(보통 감소)
const dropPct = prev && prev > 0 && drop != null && drop < 0 ? Math.round((drop / prev) * 100) : null;
return (
// 막대는 고정 폭(14rem)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로.
<li key={i} className="grid grid-cols-[6.5rem_14rem_2.25rem_1fr] items-center gap-x-2 text-[12px]">
<span className="truncate text-ink-500">{r.label}</span>
<div className="h-4 overflow-hidden rounded bg-surface-2">
<div className="h-full rounded bg-gradient-to-r from-primary-400 to-primary-600"
style={{ width: `${(r.value / max) * 100}%`, minWidth: r.value > 0 ? "3px" : 0 }} aria-hidden />
</div>
<span className="tnum text-right font-semibold">{r.value}</span>
{/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */}
<span className={`tnum whitespace-nowrap text-right text-[11px] ${drop != null && drop < 0 ? "font-semibold text-dead-600" : "text-ink-400"}`}>
{drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"}
</span>
</li>
);
})}
</ul>
);
}
// ── 폼 필드(라벨-인풋 연결, Settings 패턴 준수) ──────────────
function Field({ id, label, value, onChange, placeholder, required, inputMode }: {
id: string; label: string; value: string; onChange: (v: string) => void;
placeholder?: string; required?: boolean; inputMode?: "numeric";
}) {
return (
<div>
<label htmlFor={id} className="mb-1 block text-[12px] font-semibold text-ink-700">
{label}{required && <span className="ml-0.5 text-dead-600">*</span>}
</label>
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
inputMode={inputMode} autoComplete="off"
className="w-full rounded-lg border border-line-200 bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary-600" />
</div>
);
}

18
lps-admin/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/api/client.ts","./src/api/types.ts","./src/components/layout.tsx","./src/components/ui.tsx","./src/lib/format.ts","./src/pages/costs.tsx","./src/pages/crawl.tsx","./src/pages/dashboard.tsx","./src/pages/jobs.tsx","./src/pages/products.tsx","./src/pages/settings.tsx"],"version":"5.6.3"}

19
lps-admin/vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// dev 서버는 /v1·/healthz·/readyz 를 LPS API(:9600)로 프록시한다 — CORS 설정 불필요.
// 다른 API 호스트를 보려면 VITE_LPS_URL 로 대상 변경(또는 앱 내 설정에서 base URL 지정).
const target = process.env.VITE_LPS_URL || "http://localhost:9600";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
proxy: {
"/v1": { target, changeOrigin: true },
"/healthz": { target, changeOrigin: true },
"/readyz": { target, changeOrigin: true },
},
},
});

View File

@ -1,672 +0,0 @@
<!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>

View File

@ -1,27 +0,0 @@
#!/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

View File

@ -39,7 +39,7 @@
**핵심 포인트**
- **즉시 응답 + 나중 처리**: 요청하면 바로 "접수번호(job_id)"를 주고, 실제 검색은 뒤에서 진행됩니다. (검색은 몇 초~수십 초 걸림)
- **못 찾으면 검색어를 바꿔 재시도**: "맥심 커피"로 안 나오면 "맥심 모카골드 커피믹스"처럼 **AI가 검색어를 다듬어** 다시 시도하고, 그래도 없으면 "없음"으로 정리합니다. (무한 재시도 안 함)
- **차단 대응**: 쿠팡(Akamai)·G마켓(Cloudflare 사람확인) 등이 봇으로 감지하면 **다른 IP로 바꿔** 재시도하고, 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
- **차단 대응**: IP당 요청 예산(기본 3회)에 닿으면 **차단당하기 전에 IP를 선제 교체**하고(평판 보존 — 그 IP는 로테이션 복귀 시 재사용), 그래도 감지되면 그 포트를 쿨다운 격리 후 다른 IP로 재시도합니다. 시작 시 챌린지를 미리 풀어(웜업) 실 작업을 빠르게 합니다.
- **원가 투명**: 검색 1건이 쓴 AI 비용·프록시 대역폭·시간을 함께 기록합니다.
---
@ -49,14 +49,16 @@
| 기능 | 설명 |
|------|------|
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 |
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`LPS_FALLBACKS`, [배경](docs/decision-openmarket-crawler.md)) |
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`[WorkerConfig].fallbacks`, [배경](docs/decision-openmarket-crawler.md)) |
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) |
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 |
| 검색 원가 계측 | 검색 1건의 AI 토큰·비용 + DECODO 대역폭(실측 CDP) + 시간을 집계 |
| 다중 상품 병렬 | 워커별 브라우저 세트로 여러 상품 동시 검색(`WORKER_CONCURRENCY`) |
| 안정적 큐 처리 | 작업 유실 없이 순서대로, 실패 시 자동 재시도 |
| 프록시 IP 회전 | 봇 감지·전송오류 시 IP 자동 순환 + 시작 웜업(DECODO) |
| 프록시 IP 선제 회전 | 요청 예산(기본 3회) 도달 시 **차단 전 선제 교체** + 불탄 포트 쿨다운 + 봇 감지·전송오류 즉시 순환 + 시작 웜업(DECODO). 예산 튜닝용 `ip_session` 관측 로그 |
| 임계 알림 | 큐·차단·DB풀·소스별 장기실패·비용 등 10룰 — 쿨다운(스팸 방지)·해소 알림, Slack 웹훅([룰 표](docs/operations.md)) |
| API guard | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방 모드) |
---
@ -84,6 +86,19 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
-d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}'
```
**서버 실행 — Docker**
```bash
# 리포 루트에서 전체 스택과 함께 (권장)
docker compose up -d # negosium 스택 + lps-api·lps-worker·lps-admin 모두 기동
# lps 서브셋만 (대화형: 설정 검증·guard 키 안전장치 + admin 포함)
./run_docker.sh
```
> 환경 구분이 없습니다(도메인 backend·negodata·agent 와 동일) — 항상 `config.local.toml`.
> **prod 서버**도 그 서버의 `config.local.toml` 에 prod 값(시크릿·guard 키·스케일)을 채우고 그냥 `docker compose up -d`.
> DB 는 컨테이너에서 `host.docker.internal`(호스트 DB)로 접속하고, 관리형 DB 면 `LPS_DB_HOST=` 로 override 를 끄고 toml 의 호스트를 씁니다.
> 운영에서 API 를 외부에 열지 않으려면 `export LPS_API_BIND=127.0.0.1`(리버스프록시 뒤).
> 자세한 실행/설정은 [운영 가이드](docs/operations.md) 참고.
---
@ -93,7 +108,7 @@ curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
| 문서 | 대상 | 내용 |
|------|------|------|
| **[아키텍처](docs/architecture.md)** | 개발자/기획자 | 구성요소·파이프라인·안티봇(Akamai/Turnstile)·비용계측·동시성 |
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 4종 구조와 코드값(+by_mall) |
| **[데이터베이스](docs/database.md)** | 개발자/기획자 | 테이블 5종 구조와 코드값(+by_mall·ip_session) |
| **[API 사용법](docs/api.md)** | 연동 개발자 | 엔드포인트·요청/응답·metrics 예시 |
| **[운영 가이드](docs/operations.md)** | 운영자/개발자 | 실행·병렬·관측(readyz/ops/알림)·**Docker 배포**·문제 해결 |
| **[크롤러 논의](docs/decision-openmarket-crawler.md)** | 팀 | 오픈마켓 크롤러 유지 여부(ROI) 의사결정 메모 |
@ -109,16 +124,17 @@ lps/
├── Dockerfile # API 이미지(lean) · Dockerfile.worker # 워커(Chromium+Xvfb)
├── run_local_server.sh # 로컬 API 실행 (대화형)
├── run_local_worker.sh # 로컬 워커 실행 (대화형: 동시성·프로필)
├── run_docker.sh # lps 서브셋 Docker 실행 (대화형: 설정 검증·guard 안전장치 + admin 포함)
├── run_loadtest_gui.sh # 부하 테스트 Locust 웹 UI(:8089) 실행 (대화형)
├── config/ # 설정(config.local.toml — 포트/DB/API키, 미커밋; 배포는 env 주입)
├── common/ # 공통(enums, DB 세션, 모델, 로거)
├── common/ # 공통(enums, DB 세션, 모델, 로거, alerts=임계 알림 관리자)
│ └── database/model/models.py # DB 테이블 정의
├── loadtest.py # 부하 테스트 (N개 상품 → 처리량·지연·비용 집계)
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection)
├── crud/ # DB 접근 (job_crud, price_history, negative_cache, bot_detection, ip_session)
├── services/
│ ├── search/ # 소스 어댑터 (coupang, naver, esm=G마켓·옥션, st11=11번가)
│ │ ├── browser_base.py # patchright 공통(수명·프록시회전·차단감지·CDP 바이트계측)
│ │ ├── proxy.py # DECODO(IP 회전·프리플라이트)
│ │ ├── proxy.py # DECODO(IP 회전·포트 쿨다운·프리플라이트)
│ │ └── card_parser.py # 오픈마켓 공용 카드 파서
│ ├── pipeline/ # 필터·이상치·최저가 정렬(+몰별 분해)
│ ├── ai/ # AI 유사도 판정·검색어 생성 (OpenAI)

80
lps/common/alerts.py Normal file
View File

@ -0,0 +1,80 @@
"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용.
기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다.
AlertManager 는 룰 키별로 상태를 관리한다:
발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드
회복: 활성→비활성 전환 시 '해소' 알림 1회
채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시).
sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능.
"""
import time
import httpx
from common.logger import LOG
from config.server_configs import alert_config
class AlertManager:
def __init__(self, origin: str = "worker", webhook: str | None = None,
cooldown_sec: float | None = None, sender=None, clock=time.monotonic):
self.origin = origin # 알림 출처(worker/api) — 메시지에 표기
self._webhook = webhook if webhook is not None else alert_config.webhook
self._cooldown = cooldown_sec if cooldown_sec is not None else alert_config.cooldown_min * 60
self._sender = sender # async def(text: str) — 테스트 주입용(없으면 웹훅)
self._clock = clock
self._state: dict[str, dict] = {} # key → {"active": bool, "last_sent": float}
async def check(self, key: str, active: bool, message: str, snap: dict | None = None):
"""룰 1개 평가. active 가 True 로 지속돼도 쿨다운 안에는 재발송하지 않는다."""
st = self._state.setdefault(key, {"active": False, "last_sent": 0.0})
now = self._clock()
if active:
due = (not st["active"]) or (now - st["last_sent"] >= self._cooldown)
st["active"] = True
if due:
st["last_sent"] = now
LOG.w(f"[alert:{key}] {message}")
await self._send(f":rotating_light: LPS({self.origin}) [{key}] {message}", snap)
elif st["active"]:
st["active"] = False
LOG.i(f"[alert:{key}] 해소 — {message}")
await self._send(f":white_check_mark: LPS({self.origin}) [{key}] 해소 — {message}", snap)
def is_active(self, key: str) -> bool:
return self._state.get(key, {}).get("active", False)
async def _send(self, text: str, snap: dict | None):
if self._sender is not None:
await self._sender(text)
return
if not self._webhook:
return
try:
async with httpx.AsyncClient(timeout=5) as c:
await c.post(self._webhook, json={"text": text + (f"\n```{snap}```" if snap else "")})
except Exception:
pass # 알림 실패가 모니터 루프를 막지 않는다
async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | None = None):
"""DB 커넥션 풀 포화 감시 루프 — API 프로세스용 경량 모니터(lifespan 에서 기동).
대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있어, 워커 ops-monitor 와 별도로 감시한다."""
import asyncio
from common.database.db_session_manager import DB_SESSION_MNG
alerts = alerts or AlertManager(origin="api")
threshold = alert_config.pool_pct
while not stop.is_set():
try:
st = DB_SESSION_MNG.pool_status()
await alerts.check("db_pool", st["pct"] >= threshold,
f"DB 풀 포화 {st['pct']}% (checked_out {st['checked_out']}/{st['capacity']})", st)
except Exception as ex:
LOG.e_no_callstack(f"[pool-monitor] {type(ex).__name__}: {ex}")
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
except asyncio.TimeoutError:
pass

View File

@ -84,6 +84,17 @@ class DBSessionManager(Singleton):
)
return scoped_session
def pool_status(self) -> dict:
"""전 엔진 합산 커넥션 풀 사용 현황 — 포화 감시(알림)·/ops 노출용.
checked_out=현재 사용 중, capacity=(pool_size+max_overflow)×엔진수, pct=포화율(%)."""
checked_out = capacity = 0
for engine in self.__engines:
pool = engine.sync_engine.pool
checked_out += pool.checkedout()
capacity += pool.size() + getattr(pool, "_max_overflow", 0)
pct = round(checked_out * 100 / capacity) if capacity else 0
return {"checked_out": checked_out, "capacity": capacity, "pct": pct}
async def dispose_all(self):
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.

View File

@ -105,6 +105,33 @@ class price_history(MAIN_BASE):
)
class ip_session(MAIN_BASE):
"""IP(프록시 포트) 세션 종료 이력 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'를 매 세션 기록.
bot_detection 은 차단된 세션만 남지만 여기엔 무사 종료도 남아, 요청 예산(LPS_IP_REQUEST_BUDGET)
상한 튜닝의 원천 데이터가 된다. (예: end_reason='block' 의 requests 분포 → 안전 상한 산출)"""
@staticmethod
def DBType():
return DBType.MAIN.value
__tablename__ = "ip_session"
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
source = Column(String(20), nullable=False) # coupang 등
proxy_port = Column(Integer, nullable=True) # 사용 포트(=IP 세션), 프록시 미사용이면 NULL
requests = Column(Integer, nullable=False) # 이 IP 로 보낸 요청 수
ok_count = Column(Integer, nullable=False, server_default=text("0")) # 성공 검색 수
blocked_count = Column(Integer, nullable=False, server_default=text("0")) # 차단 감지 수
elapsed_sec = Column(Integer, nullable=True) # 세션 지속 시간(초)
end_reason = Column(String(20), nullable=False) # budget/block/proxy_error/window/idle/shutdown/rotate
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 세션 종료 시각
__table_args__ = (
# 상한 튜닝 쿼리(소스·기간별 종료 사유 분포) 최적화
Index("ix_ip_session_source", "source", "created_at"),
)
class bot_detection(MAIN_BASE):
"""봇 감지 이력 — '이 IP로 몇 번째 요청에서, 어떤 방식으로 차단됐나'를 축적해 패턴 분석.
(예: SELECT avg(ip_request_no) → IP당 평균 몇 요청 만에 감지되는지)"""

View File

@ -1,14 +1,11 @@
# 복사해서 사용: 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 로 채우면 이미지에 시크릿이 안 남는다.
# ── 설정 소스는 TOML 하나다(2026-07-13 협의 — env/.env 이중 관리 제거) ──
# 호스트 실행: APP_ENV=local(기본) → 이 파일
# Docker : APP_ENV=dev|prod → config.dev.toml / config.prod.toml 을 컨테이너에 마운트
# (각 example 참고, 실행은 ../run_docker.sh 대화형 권장)
# env 는 APP_ENV·PROCESS_COUNT/WORKER_CONCURRENCY(실행 스크립트 대화형 입력)·LPS_LIVE(테스트)만 남는다.
[WebServerConfig]
server_name = "LpsServer"
port = 9600
@ -17,13 +14,15 @@ is_ssl = false
is_test = true
# CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 5173=vite dev.
cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
# API guard 키 — 비우면 개방 모드(개발). prod 는 채운다(복수 등록 = 무중단 키 교체).
# 생성 예: openssl rand -hex 32. 호출자(negodata)도 같은 키를 설정해야 한다.
api_keys = []
[LogConfig]
print_console = true
log_level = "debug"
# DB Read/Write 분리. 도커 실행 시 host 는 docker-compose 의 DB_HOST 로 override.
# 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require".
# DB Read/Write 분리. 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require".
# LPS 도메인 로직/테이블이 생기기 전까지는 접속하지 않으므로(엔진 lazy) placeholder 여도 부팅된다.
[MainDBConfig]
db_type = "postgresql"
@ -43,10 +42,35 @@ max_overflow = 20 # 〃
# (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
connection_budget = 40
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). 배포는 이 파일 마운트 권장. ──
# 워커 런타임 (worker_main.py). 동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능.
[WorkerConfig]
concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피)
job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
chrome_channel = "chrome" # 로컬: 실제 Chrome
chrome_executable = "" # 컨테이너: "/usr/bin/chromium" (설정 시 channel 무시)
heartbeat_file = "/tmp/lps_worker_heartbeat" # Docker HEALTHCHECK 가 신선도 확인
# 임계 알림 (AlertManager — 룰 의미는 docs/operations.md 표)
[AlertConfig]
webhook = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림
cooldown_min = 30 # 같은 룰 재발송 억제(분). 해소 알림은 즉시
dead_1h = 20 # 최근 1h DEAD 잡 수
blocks_1h = 80 # 최근 1h 봇 감지 수
queue_lag_sec = 300 # 가장 오래된 PENDING 대기 초
pool_pct = 90 # DB 커넥션 풀 포화율(%)
source_fail_30m = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건
deadline_1h = 5 # 최근 1h 잡 데드라인 강제종료 수
cost_1h_usd = 1.0 # 최근 1h 검색원가 합($)
ports_low_pct = 30 # 가용 프록시 포트 비율(%)
block_sessions_6h = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수
# ── 시크릿(API 키 등)도 이 파일에서 통합 관리 (미커밋). ──
# 네이버 쇼핑 오픈API (https://developers.naver.com/apps). 여러 개면 429/403 로테이션 자동 포함.
[NaverConfig]
@ -69,6 +93,8 @@ host = "" # 예: gate.decodo.com
username = "" # 대시보드 USERNAME (예: sppd6a3ze3)
password = "" # 대시보드 PASSWORD
port_start = 0 # 예: 10001
port_end = 0 # 예: 10010
port_end = 0 # 예: 10010 — 포트를 늘리면(계약 변경) 이 범위만 넓히면 됨(코드 무변경)
session_minutes = 10 # 대시보드 Sticky 지속시간(분)과 일치
cost_per_gb = 0.0 # DECODO 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게, 예 3.0)
ip_request_budget = 3 # IP당 요청 예산 — 도달 시 차단 전 선제 회전(0=비활성). 튜닝은 docs/database.md
port_cooldown_sec = 0 # 차단 감지 포트 격리 초. 0=자동 max(sticky, 30분)

View File

@ -11,6 +11,9 @@ class WebServerConfig(ConfigModel):
is_test: bool = False
# CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"]
cors_origins: list[str] = []
# API guard 키. 비우면 개방 모드(개발). 채우면 /v1 전체에 X-API-Key 검증(prod).
# 여러 개 등록 가능 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거).
api_keys: list[str] = []
class LogConfig(ConfigModel):
@ -76,3 +79,37 @@ class DecodoConfig(ConfigModel):
port_end: int = 0
session_minutes: int = 10
cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정)
# IP(포트 세션)당 요청 예산 — 도달 시 차단당하기 전에 선제 회전(평판 보존). 0=비활성.
# 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md).
ip_request_budget: int = 3
# 차단 감지된 포트 격리 시간(초). 0=자동(max(sticky, 30분)) — sticky 만료 후 복귀라 사실상 새 IP.
port_cooldown_sec: int = 0
class WorkerConfig(ConfigModel):
"""워커 런타임 설정. (동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능 — 대화형 스크립트용)"""
concurrency: int = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
fallbacks: list[str] = [] # 오픈마켓 폴백(기본 비활성). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
profile_dir: str = "/tmp" # Chrome 프로필 베이스 경로. 영속 볼륨이면 재시작에도 cf_clearance 유지(재웜업 회피)
job_deadline_sec: float = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
shutdown_grace_sec: float = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
chrome_channel: str = "chrome" # 로컬: 실제 Chrome 채널
chrome_executable: str = "" # 컨테이너: 시스템 chromium 경로(설정 시 channel 무시, --no-sandbox 적용)
heartbeat_file: str = "/tmp/lps_worker_heartbeat" # 하트비트 파일(Docker HEALTHCHECK 신선도 확인)
class AlertConfig(ConfigModel):
"""임계 알림(AlertManager) 설정 — 룰 의미는 docs/operations.md 표 참고."""
webhook: str = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림
cooldown_min: int = 30 # 같은 룰 재발송 억제 시간(분). 해소 알림은 즉시
dead_1h: int = 20 # 최근 1h DEAD 잡 수 임계
blocks_1h: int = 80 # 최근 1h 봇 감지 수 임계
queue_lag_sec: int = 300 # 가장 오래된 PENDING 대기 초 임계
pool_pct: int = 90 # DB 커넥션 풀 포화율(%) 임계
source_fail_30m: int = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건
deadline_1h: int = 5 # 최근 1h 잡 데드라인 강제종료 수 임계
cost_1h_usd: float = 1.0 # 최근 1h 검색원가 합($) 임계
ports_low_pct: int = 30 # 가용 프록시 포트 비율(%) 임계
block_sessions_6h: int = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수 임계

View File

@ -2,35 +2,51 @@ import os
from config.config_loader import Configs
from config.config_models import (
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, NaverKey, OpenAIConfig, DecodoConfig,
WebServerConfig, LogConfig, MainDBConfig, NaverConfig, OpenAIConfig, DecodoConfig,
WorkerConfig, AlertConfig,
)
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
# ── 설정 소스는 config.local.toml 하나다(도메인 backend 와 동일 — 서버마다 그 서버의 값, 미커밋). ──
# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml (호스트 실행·pytest·docker 모두 동일).
# prod 서버도 그 서버의 config.local.toml 에 prod 값(시크릿·guard키·스케일)을 채우고 `docker compose up -d`.
# env 는 실행 입력과 '환경별로 바뀌는 접속점'만 담당한다:
# DB_HOST/PORT/… : DB 접속 override — config.local.toml 은 127.0.0.1(호스트 실행·pytest) 유지하고,
# 컨테이너에선 compose 가 DB_HOST=host.docker.internal 로 호스트 DB 를 가리킨다(_apply_db_env_override).
# PROCESS_COUNT : 실행 스크립트·부하벤치의 대화형 입력(uvicorn 워커 수 임시 override)
# WORKER_CONCURRENCY : 워커 실행 스크립트의 대화형 입력(worker_main 이 읽음)
# LPS_LIVE : 라이브 스모크 테스트 옵트인(설정이 아니라 실행 스위치)
# 시크릿(네이버·OpenAI·DECODO)은 env 로 넣지 않는다 — config.local.toml 에 두고 컨테이너엔 마운트한다.
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 로 실행하세요.")
raise FileNotFoundError(
f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). "
"config.local.toml 을 준비하세요(config.local.toml.example 복사). 컨테이너는 compose 가 이 파일을 마운트합니다."
)
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()
worker_config: WorkerConfig = configs.get(WorkerConfig) or WorkerConfig()
alert_config: AlertConfig = configs.get(AlertConfig) or AlertConfig()
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
# DB 접속 env override — config.local.toml 의 접속 정보를 '환경별로 바뀌는 값만' 덮는다.
# 로컬(호스트 uvicorn·pytest)은 env 미설정 → toml(127.0.0.1) 그대로. 컨테이너는 compose 가
# DB_HOST=host.docker.internal 을 넣어 호스트 DB 를 가리킨다(도메인 backend 와 동일한 패턴).
# 값이 빈 문자열이면 override 하지 않는다 → 관리형 DB 는 LPS_DB_HOST= 로 비워 config.local.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_HOST"):
cfg.write_host = cfg.read_host = os.environ["DB_HOST"]
if os.environ.get("DB_PORT"):
cfg.write_port = cfg.read_port = int(os.environ["DB_PORT"])
if os.environ.get("DB_USER"):
@ -39,7 +55,7 @@ def _apply_db_env_override(cfg: MainDBConfig):
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.
# 커넥션 예산도 env 로 조정 가능(자동 산정 입력) — 전용/공유 PG 에 맞춰 서버별로.
if os.environ.get("DB_CONNECTION_BUDGET"):
cfg.connection_budget = int(os.environ["DB_CONNECTION_BUDGET"])
@ -66,49 +82,13 @@ def _autosize_pool(cfg: MainDBConfig, process_count: int):
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)]
# DB 접속점 override(호스트↔컨테이너) — 자동 산정(예산) 전에 적용해야 budget override 가 반영된다.
_apply_db_env_override(main_db_config)
_apply_secret_env_override()
# uvicorn 워커 수(멀티코어) env override — 부하테스트에서 1↔N 비교용(코드/toml 수정 없이).
# uvicorn 워커 수 — 실행 스크립트/부하벤치의 대화형 입력만 env 로 임시 override(설정은 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)

View File

@ -27,6 +27,27 @@ class BotDetectionLog:
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
async def admin_stats(self, hours: int = 168) -> dict:
"""관리자 FE 용 차단 통계 — 시간대별 건수 + 최근 감지 목록."""
p = {"h": hours}
hourly = text("""
SELECT date_trunc('hour', created_at) AS bucket, count(*) AS n FROM bot_detection
WHERE created_at > now() - make_interval(hours => :h) GROUP BY 1 ORDER BY 1
""")
recent = text("""
SELECT source, query, ip_request_no, proxy_port, elapsed_sec, marker, html_len, created_at
FROM bot_detection WHERE created_at > now() - make_interval(hours => :h)
ORDER BY created_at DESC LIMIT 50
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return {
"hourly": [{"bucket": r[0].isoformat(), "count": int(r[1])} for r in (await s.execute(hourly, p)).all()],
"items": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
}
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.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)")

73
lps/crud/ip_session.py Normal file
View File

@ -0,0 +1,73 @@
"""IP 세션 종료 이력 CRUD — 세션당 요청 수·종료 사유를 축적(요청 예산 상한 튜닝용)."""
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
class IpSessionLog:
DB = DBType.MAIN.value
async def record(self, event: dict):
"""세션 종료 이벤트 1건 저장. 기록 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다."""
sql = text("""
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason)
VALUES (:source, :proxy_port, :requests, :ok_count, :blocked_count, :elapsed_sec, :end_reason)
""")
params = {k: event.get(k) for k in
("source", "proxy_port", "requests", "ok_count", "blocked_count", "elapsed_sec", "end_reason")}
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 admin_stats(self, hours: int = 168) -> dict:
"""관리자 FE 용 IP 세션 통계 — 종료 사유 분포·세션당 요청 수 히스토그램·
차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션 목록."""
p = {"h": hours}
by_reason = text("""
SELECT end_reason, count(*) AS n FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) GROUP BY end_reason
""")
histogram = text("""
SELECT requests, count(*) AS n FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) GROUP BY requests ORDER BY requests
""")
block_min = text("""
SELECT min(requests) FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) AND end_reason = 'block'
""")
recent = text("""
SELECT source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason, created_at
FROM ip_session WHERE created_at > now() - make_interval(hours => :h)
ORDER BY created_at DESC LIMIT 50
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return {
"by_reason": {r[0]: int(r[1]) for r in (await s.execute(by_reason, p)).all()},
"histogram": [{"requests": int(r[0]), "count": int(r[1])} for r in (await s.execute(histogram, p)).all()],
"block_min_requests": (lambda v: int(v) if v is not None else None)((await s.execute(block_min, p)).scalar()),
"sessions": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
}
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
async def recent_stats(self, minutes: int = 60) -> dict:
"""최근 N분 세션 요약 — 종료 사유별 건수(모니터링·알림용). 예: {"budget": 12, "block": 1}"""
sql = text("""
SELECT end_reason, count(*) FROM ip_session
WHERE created_at > now() - make_interval(mins => :m) GROUP BY end_reason
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
rows = (await s.execute(sql, {"m": minutes})).all()
return {r[0]: int(r[1]) for r in rows}
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)

View File

@ -217,13 +217,98 @@ class JobQueue:
(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
COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at) FILTER (WHERE status = 1)))::int, 0) AS oldest_pending_sec,
-- 데드라인 강제종료(크롤 행 신호). 재시도로 살아나면 dead 엔 안 잡혀 별도 집계.
count(*) FILTER (WHERE last_error LIKE 'JobDeadlineExceeded%'
AND updated_at > now() - interval '1 hour') AS deadline_1h,
-- 최근 1h 완료 잡의 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시.
COALESCE(sum((result #>> '{metrics,cost,total_usd}')::float)
FILTER (WHERE status = 3 AND updated_at > now() - interval '1 hour'), 0) AS cost_1h_usd
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()}
snap = dict((await s.execute(sql)).mappings().first())
cost = float(snap.pop("cost_1h_usd") or 0)
snap = {k: int(v) for k, v in snap.items()}
snap["cost_1h_usd"] = round(cost, 4)
return snap
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
# ---- 관리자(FE) 조회/액션 -------------------------------------------
async def list_jobs(self, status: int | None = None, q: str | None = None,
limit: int = 50, offset: int = 0) -> tuple[list[dict], int]:
"""잡 목록(최신순) + 전체 건수. status(코드)·q(product_code/상품명 부분일치) 필터."""
where = ["TRUE"]
params: dict = {"limit": limit, "offset": offset}
if status is not None:
where.append("status = :st")
params["st"] = status
if q:
where.append("(payload->>'product_code' ILIKE :q OR payload->>'product_name' ILIKE :q)")
params["q"] = f"%{q}%"
cond = " AND ".join(where)
sql = text(f"""
SELECT job_id, job_type, status, priority, attempts, max_attempts,
payload->>'product_code' AS product_code, payload->>'product_name' AS product_name,
result->>'outcome' AS outcome,
(result#>>'{{lowest,price}}')::int AS final_lowest,
(result#>>'{{metrics,cost,total_usd}}')::float AS cost_usd,
last_error, created_at, run_started_at, updated_at
FROM job WHERE {cond}
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
""")
cnt = text(f"SELECT count(*) FROM job WHERE {cond}")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
rows = [dict(r) for r in (await s.execute(sql, params)).mappings().all()]
total = int((await s.execute(cnt, params)).scalar() or 0)
for d in rows:
d["job_id"] = str(d["job_id"])
return rows, total
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
async def requeue(self, job_id: str) -> str | None:
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크
위반(IntegrityError) — 호출부가 '활성 중복'으로 안내한다."""
sql = text("""
UPDATE job SET status = 1, attempts = 0, run_after = now(),
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
last_error = NULL, updated_at = now()
WHERE job_id = CAST(:jid AS uuid) AND status = 4
RETURNING job_id
""")
async def run(s):
row = (await s.execute(sql, {"jid": job_id})).first()
if row:
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)
async def cost_buckets(self, hours: int = 48) -> list[dict]:
"""시간별 검색원가 집계(완료 잡의 metrics 합산) — 비용 차트용."""
sql = text("""
SELECT date_trunc('hour', updated_at) AS bucket,
count(*) AS jobs,
COALESCE(sum((result#>>'{metrics,cost,ai_usd}')::float), 0) AS ai_usd,
COALESCE(sum((result#>>'{metrics,cost,proxy_usd}')::float), 0) AS proxy_usd,
COALESCE(sum((result#>>'{metrics,cost,total_usd}')::float), 0) AS total_usd,
COALESCE(avg((result#>>'{metrics,duration_ms}')::float), 0) AS avg_ms
FROM job
WHERE status = 3 AND updated_at > now() - make_interval(hours => :h)
GROUP BY 1 ORDER BY 1
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return [{"bucket": r["bucket"].isoformat(), "jobs": int(r["jobs"]),
"ai_usd": round(float(r["ai_usd"]), 4), "proxy_usd": round(float(r["proxy_usd"]), 4),
"total_usd": round(float(r["total_usd"]), 4), "avg_ms": int(r["avg_ms"])}
for r in (await s.execute(sql, {"h": hours})).mappings().all()]
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)

View File

@ -65,3 +65,27 @@ class PriceHistory:
return [dict(r) for r in rows]
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
async def list_products(self, q: str | None = None, limit: int = 50) -> list[dict]:
"""이력이 있는 상품 목록(관리자 FE) — 상품별 최신 스냅샷 + 검색 횟수, 최근 검색순.
상품명은 price_history 에 없어 최신 스냅샷의 매칭 상품명(네이버 우선)으로 대신한다."""
where = "WHERE product_code ILIKE :q" if q else ""
sql = text(f"""
SELECT * FROM (
SELECT DISTINCT ON (product_code)
product_code, triggered_at, outcome,
naver_lowest, coupang_lowest, final_lowest, final_source,
COALESCE(naver_name, coupang_name) AS display_name,
count(*) OVER (PARTITION BY product_code) AS searches
FROM price_history {where}
ORDER BY product_code, triggered_at DESC
) t ORDER BY triggered_at DESC LIMIT :lim
""")
params: dict = {"lim": limit}
if q:
params["q"] = f"%{q}%"
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return [dict(r) for r in (await s.execute(sql, params)).mappings().all()]
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)

View File

@ -9,6 +9,10 @@
"result": { "success": true, "code": 0, "desc": "SUCCESS" }
```
(실패 시 `success:false`, `code`/`desc`에 오류 코드)
- **인증(guard)**: 서버 toml 의 `[WebServerConfig].api_keys` 가 채워진 환경(prod)에서는 모든 `/v1/*` 요청에
`X-API-Key` 헤더가 필요합니다(불일치 시 `401`). 개발(local/dev)은 키를 비워 **개방 모드**로
동작합니다. `/healthz`·`/readyz` 는 항상 개방(LB 프로브). 키는 리스트로 복수 등록 가능
(무중단 키 교체). 호출 예: `curl -H "X-API-Key: <키>" http://.../v1/lps/queue/stats`
---
@ -148,11 +152,29 @@ curl "localhost:9600/v1/lps/products/T1/history?limit=100"
{ "result": {...}, "counts": { "PENDING": 0, "RUNNING": 1, "DONE": 12, "DEAD": 0 } }
```
## 5. 헬스체크 — `GET /healthz`
서버 기동 시각을 반환(살아있는지 확인용).
## 5. 운영 스냅샷 — `GET /v1/lps/ops`
외부 모니터가 스크랩·임계 알림하기 좋은 **플랫 JSON**. 알림 룰·임계는 [운영 가이드](operations.md) 참고.
```json
{
"pending": 0, "running": 1, "done": 12, "dead": 0,
"dead_1h": 0, // 최근 1h 재시도 소진 실패
"stuck_running": 0, // lease 만료/장기 실행 좀비 신호
"oldest_pending_sec": 3, // 큐 지연(가장 오래된 대기 잡)
"blocks_1h": 0, // 최근 1h 봇 감지 수
"deadline_1h": 0, // 최근 1h 잡 데드라인 강제종료(크롤 행 신호)
"cost_1h_usd": 0.09, // 최근 1h 완료 잡 검색원가 합($)
"pool_checked_out": 0, "pool_capacity": 40, "pool_pct": 0 // API 프로세스 DB 풀 사용률
}
```
## 6. 헬스체크 — `GET /healthz` · `GET /readyz`
`/healthz`: 서버 기동 시각 반환(liveness — 프로세스 생존만). `/readyz`: DB 도달성까지 확인(실패 시 503) — LB/오케스트레이터용. 둘 다 **guard 대상이 아니라 항상 개방**.
---
## 상태 코드 요약
- **작업 상태**: `PENDING`(대기) · `RUNNING`(처리중) · `DONE`(완료) · `DEAD`(실패-확인필요)
- **결과 outcome**: `found`(찾음) · `not_found`(검색했으나 같은 상품 없음)
- **HTTP 401**: guard 활성 환경에서 `X-API-Key` 누락/불일치 — 키 주입 확인(상단 인증 참고)

View File

@ -10,9 +10,11 @@
| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) |
| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 |
| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) |
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`LPS_FALLBACKS`) |
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`[WorkerConfig].fallbacks`) |
| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 |
| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) |
| **관측·알림** | `common/alerts.py` + 워커 ops-monitor | 큐·차단·DB풀·비용 등 10룰 임계 알림(쿨다운·해소 알림, Slack 웹훅) + 하트비트. API 도 자기 풀을 자체 감시. [룰 표](operations.md) |
| **API guard** | `router/v1/validator/auth.py` | `[WebServerConfig].api_keys` 설정 시 `/v1` 전체 X-API-Key 검증(개발은 빈값=개방) |
> **API와 워커를 분리**한 이유: 요청 접수는 즉시(가벼움), 실제 검색은 무거움(브라우저·AI). 분리하면 요청이 밀리지 않고, 워커만 따로 늘릴 수 있습니다.
@ -41,7 +43,7 @@
→ 매칭 0건 + 소스 정상: 다음 라운드로
→ 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_FALLBACKS 로 켬**)
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — [WorkerConfig].fallbacks 로 켬**)
네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합
("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리)
※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지).
@ -78,6 +80,7 @@
**핵심 메커니즘**
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`[DecodoConfig].ip_request_budget`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`[DecodoConfig].port_cooldown_sec`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md).
- **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름).
- **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다.
- **폴백 데드라인**: 오픈마켓 크롤은 '보강'이라 각 크롤에 시간 상한(기본 15초)을 둬, 한 몰이 안 풀려도 전체 지연이 늘지 않게 합니다.
@ -104,7 +107,7 @@
- **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.
- **예산 가이드**: 공유 PG=40(API+worker+타 서비스 공존, 안정 우선) / 전용 PG(`max_connections≈100`)=90(처리량 우선). `[MainDBConfig].connection_budget`. 더 큰 처리량은 예산↑ + PG `max_connections`↑ 또는 pgbouncer.
- 상세·벤치 결과: [`../loadtest/README.md`](../loadtest/README.md).
### 6-2. 왜 브라우저는 워커당 1세트인가 (더 띄우면 안 되나?)

View File

@ -6,7 +6,7 @@
- **테이블 정의**: `common/database/model/models.py` (SQLAlchemy) — 이 파일이 스키마의 단일 출처
- **공통 규칙**: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 `TIMESTAMPTZ`(UTC)
## 테이블 4종 한눈에
## 테이블 5종 한눈에
| 테이블 | 용도 |
|--------|------|
@ -14,6 +14,7 @@
| `price_history` | 최저가 이력 — 그래프용 시계열 스냅샷 |
| `search_negative` | 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억 |
| `bot_detection` | 봇 감지 이력 — 쿠팡이 차단한 패턴 기록 |
| `ip_session` | IP 세션 종료 이력 — 요청 예산(선제 회전) 상한 튜닝 데이터 |
---
@ -102,6 +103,33 @@ SELECT avg(ip_request_no), count(*) FROM bot_detection;
---
## 5. `ip_session` — IP(프록시 포트) 세션 종료 이력
브라우저(=IP 세션)가 끝날 때마다 기록. `bot_detection`은 **차단된** 세션만 남지만,
여기엔 **무사 종료**(예산 선제 회전·시간창 만료 등)도 남아 요청 예산(`[DecodoConfig].ip_request_budget`)
상한 튜닝의 원천 데이터가 됩니다.
| 컬럼 | 뜻 |
|------|-----|
| `source` | 소스(coupang 등) |
| `proxy_port` | 사용 포트(=IP 세션). 프록시 미사용이면 NULL |
| `requests` | 이 IP로 보낸 요청 수 |
| `ok_count` / `blocked_count` | 성공 검색 수 / 차단 감지 수 |
| `elapsed_sec` | 세션 지속 시간(초) |
| `end_reason` | 종료 사유 — `budget`(예산 선제) / `block`(차단) / `proxy_error`(포트 사망) / `window`(시간창 만료) / `idle`(유휴 정리) / `shutdown`(종료) |
| `created_at` | 세션 종료 시각 |
**예산 튜닝 쿼리** — 차단이 나기 시작하는 요청 수 분포를 보고 상한을 조정:
```sql
-- 종료 사유별 분포(최근 7일): budget 이 대다수 + block 0 이면 예산을 1씩 올려볼 수 있고,
-- block 이 보이면 그 세션들의 requests 최솟값보다 예산을 낮게 유지한다.
SELECT end_reason, count(*), avg(requests)::numeric(5,1) AS avg_req, min(requests), max(requests)
FROM ip_session WHERE created_at > now() - interval '7 days'
GROUP BY end_reason ORDER BY count(*) DESC;
```
---
## 스키마 생성/관리
- 개발·테스트: SQLAlchemy 모델에서 `create_all`로 자동 생성.

View File

@ -60,9 +60,9 @@ LPS는 상품별 최저가를 찾는다. 소스는 2계층:
- [x] **결정: B. 게이트/OFF** — 검색은 **네이버+쿠팡만**. 오픈마켓 폴백 3종은 **코드·테스트 유지, 로직에서 제외(기본 비활성)**.
- 근거: 크롤 몰의 최종 최저가 기여 0회 + 검색당 최대 15s(폴백 데드라인) + 비용의 ~87%(DECODO)가 이 경로.
- [x] 구현: 주석처리가 아닌 **env 토글** — `LPS_FALLBACKS`(기본 빈값=OFF, 예: `gmarket,auction,st11`, 일부만도 가능).
- [x] 구현: 주석처리가 아닌 **설정 토글** — `[WorkerConfig].fallbacks`(기본 []=OFF, 예: `["gmarket","auction","st11"]`, 일부만도 가능. 구현 당시 env `LPS_FALLBACKS`, 2026-07-13 toml 단일화로 이관).
- `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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.
- **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `[WorkerConfig].fallbacks` 설정(로컬은 config.local.toml, 배포는 config.docker.toml) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.

View File

@ -18,10 +18,12 @@ cp config/config.local.toml.example config/config.local.toml
| `[OpenAIConfig]` | `api_key` (AI 판정·검색어 생성용) |
| `[DecodoConfig]` | 프록시 정보(비워두면 프록시 미사용) |
> **한 파일에 설정+시크릿 통합** 관리(로컬). **Docker 이미지에는 이 파일이 들어가지 않는다** —
> 빌드 시 `.dockerignore` 로 제외되고 example(플레이스홀더)이 대신 들어가며, 실값은 compose 의
> env 로 주입한다(리포 루트 `.env`, 템플릿 `.env.example`). `server_configs` 의 env override 가
> DB 접속·`OPENAI_API_KEY`·`DECODO_*`(포트 포함)·`NAVER_KEYS` 를 모두 덮는다.
> **설정 소스는 `config.local.toml` 하나다**(도메인 backend·negodata·agent 와 동일). 환경 구분이 없다 —
> 호스트 실행·Docker·prod 서버 모두 같은 파일명을 쓰고, **서버마다 그 서버의 값**(시크릿·guard 키·스케일)을 담는다(미커밋).
> 컨테이너는 compose 가 이 파일을 마운트한다 — 이미지엔 시크릿이 없고(빌드 시 `.dockerignore` 제외), 마운트를
> 잊으면 기동 시 FileNotFoundError 로 즉시 실패한다. env 로는 '환경별로 바뀌는 접속점'만 준다:
> `DB_HOST`(컨테이너→호스트 DB, compose 가 `host.docker.internal` 주입 — 관리형 DB 면 `LPS_DB_HOST=` 로 끔),
> 그리고 실행 스크립트의 대화형 입력(`PROCESS_COUNT`/`WORKER_CONCURRENCY`)·`LPS_LIVE`(테스트).
**DB 준비**: `lps_db` 생성 후 최초 실행 시 테이블 자동 생성.
```bash
@ -31,6 +33,16 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry
## 2. 실행
**환경 개요** — 환경 구분이 없다. 어디서든 `config.local.toml` 하나(서버마다 그 서버의 값):
| 실행 위치 | 설정 파일 | 실행 방법 |
|----------|-----------|----------|
| 개발자 호스트(비도커) | `config.local.toml` | `./run_local_server.sh` + `./run_local_worker.sh` |
| 개발/운영 서버(Docker) | `config.local.toml` (그 서버 값) | `docker compose up -d` (또는 `./run_docker.sh` — lps 서브셋·안전장치) |
> **prod**도 별도 환경이 아니다 — prod 서버의 `config.local.toml` 에 prod 값(시크릿·`api_keys` guard·스케일)을 채우고
> `docker compose up -d`. 외부 노출 차단은 `export LPS_API_BIND=127.0.0.1`(리버스프록시 뒤).
**API 서버** (요청 접수)
```bash
./run_local_server.sh # → http://localhost:9600/docs
@ -40,16 +52,16 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry
```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 문서 참고)
PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간. 동시성·폴백 등은 config.local.toml [WorkerConfig]
WORKER_CONCURRENCY=3 python worker_main.py # 동시성만 실행 시 임시 override 가능(권장 2~3, Chrome 최대 4×N개)
# 오픈마켓 폴백 재가동: [WorkerConfig].fallbacks = ["gmarket","auction","st11"] (기본 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 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다.
- 유예시간 `[WorkerConfig].shutdown_grace_sec`(기본 60s) 안에 안 끝나면 강제 취소되고, 그 잡은 lease 만료(120s) 후 reaper 가 재큐합니다. **한 번 더 신호를 보내면 즉시 강제 종료**입니다.
- Docker 는 compose 의 `stop_grace_period: 75s`(유예 60s + 정리 여유)가 SIGKILL 을 그만큼 미뤄줍니다 — 유예를 늘리면 이 값도 같이 늘리세요.
**부하 테스트**
@ -72,8 +84,8 @@ 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`.
- **예산 조정**: 공유 PG 는 40 유지, 전용 PG(`max_connections≈100`)면 `[MainDBConfig].connection_budget = 90` 으로 상향.
- `PROCESS_COUNT` env 는 실행 스크립트·부하벤치의 대화형 입력 전용 임시 override(설정은 toml 이 소스).
- 부하 한계 측정은 [`loadtest/README.md`](../loadtest/README.md) 참고(Locust 멀티코어 벤치).
## 3. 로그 보는 법 (워커 터미널)
@ -86,7 +98,9 @@ config 가 보장: 위 값 ≤ connection_budget (기본 40)
| `[coupang] query='...' → N건 (ip_req#K)` | 쿠팡 결과 수 / 이 IP로 K번째 요청 |
| `[gmarket/auction/st11] query='...' → N건` | 오픈마켓 폴백 크롤 결과 수 |
| `[ai] 판정 N건 중 매칭 M건` | AI 같은상품 선별 결과 |
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → IP 회전 |
| `[coupang] IP 회전 — 요청예산 3회 도달` | 예산 선제 회전(정상 동작 — 차단 전 교체, 포트는 재사용됨) |
| `[proxy] 포트 10005 쿨다운 1800s — 활성 N/100` | 차단 감지된 포트 격리(만료까지 로테이션이 건너뜀) |
| `[coupang][BOT-DETECTED] ... marker='...'` | 봇 감지(마커별) → 포트 쿨다운 + IP 회전 |
| `[gmarket] IP 회전 — 프록시 전송오류/봇 감지` | 프록시 죽음(407/터널) 또는 차단 → 새 IP |
| `[fallback:gmarket] 데드라인 15s 초과 → 스킵` | 폴백 크롤이 시간 상한 초과 → 그 몰만 스킵 |
| `[coupang] 유휴 120s 초과 → 브라우저 정리` | 유휴 브라우저 닫아 메모리 회수(다음 검색 때 재기동) |
@ -140,16 +154,35 @@ SELECT key, until, reason FROM search_negative ORDER BY created_at DESC;
코어별 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
**임계 알림**(AlertManager — 워커 ops-monitor + API 풀 모니터 공용): 룰별로 상태를 관리해
발화 시 1회 + 쿨다운(기본 30분)마다 리마인드, **조건 해소 시 '해소' 알림 1회**를 보낸다
(과거처럼 조건 지속 중 30초마다 반복 발송되지 않음). WARN/INFO 로그는 항상, 웹훅은 env 있을 때만.
| 룰 키 | 조건 | 임계 [AlertConfig] 키(기본) |
|------|------|----------------|
| `dead` | 최근 1h DEAD 잡 수 | `dead_1h`(20) |
| `blocks` | 최근 1h 봇 감지 수 | `blocks_1h`(80) |
| `queue_lag` | 가장 오래된 PENDING 대기 초 | `queue_lag_sec`(300) |
| `stuck` | lease 만료 RUNNING 잔존 | (0 초과 시) |
| `db_pool` | DB 커넥션 풀 포화율(%) — 워커·API 각자 자기 풀 감시 | `pool_pct`(90) |
| `source_fail:<src>` | 소스별 최근 30분 시도 N회 이상 & 성공 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) | `source_fail_30m`(5) |
| `deadline` | 최근 1h 잡 데드라인 강제종료 수(크롤 행 반복 신호 — 재시도로 살아나면 dead 엔 안 잡힘) | `deadline_1h`(5) |
| `cost` | 최근 1h 완료 잡 검색원가 합($) — 비용 폭주(리소스차단 풀림·재시도 루프) 감시 | `cost_1h_usd`(1.0) |
| `proxy_ports_low` | 가용 프록시 포트 비율(%) — 쿨다운 격리 누적, blocks 보다 먼저 우는 대규모 차단 조기 신호 | `ports_low_pct`(30) |
| `budget_leak` | 최근 6h '예산 회전에도 차단된' IP 세션 수 — 현재 요청 예산이 안전하지 않다는 신호(예산 하향 검토) | `block_sessions_6h`(1) |
```toml
[AlertConfig]
webhook = "https://hooks.slack.com/..." # 있으면 웹훅 알림 전송(워커·API 공통)
cooldown_min = 30 # 같은 룰 재발송 억제 시간(분)
```
지표는 알림 없이도 `GET /v1/lps/ops` 로 노출된다(`pool_pct`·`deadline_1h`·`cost_1h_usd` 포함) — 외부 모니터 스크랩용.
(`proxy_ports_avail`·`block_sessions_6h` 는 워커 웹훅 스냅샷에만 포함 — 프록시 상태는 워커 프로세스에만 있음)
## 5. 테스트
```bash
python -m pytest # 단위·통합(96) — 브라우저/네트워크 불필요
python -m pytest # 단위·통합(145) — 브라우저/네트워크 불필요
LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라이브 스모크(셀렉터·안티봇 드리프트 감지)
```
> ⚠️ **워커가 실행 중이면 테스트가 깨집니다** — 워커가 같은 `lps_db`의 테스트 작업을 가로채기 때문. 테스트 전 워커를 멈추세요:
@ -184,13 +217,21 @@ 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·프록시 미사용으로 조용히 동작하니, 기동 로그의
config.local.toml·`.profiles/` 제외). 실값은 **`lps/config/config.<env>.toml`**(dev/prod example 복사,
미커밋)을 compose 가 마운트해 주입(`APP_ENV` 선택). 마운트를 잊으면 기동 시 즉시 실패. 기동 로그의
`AI: ON/OFF`·`DECODO 프록시: ON/OFF` 로 주입 성공을 반드시 확인할 것.
- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `LPS_PROFILE_DIR`): 재시작해도 cf_clearance 유지 → 재웜업 회피.
- **Chrome 프로필 영속 볼륨**(`lps-profiles:/profiles`, `[WorkerConfig].profile_dir`): 재시작해도 cf_clearance 유지 → 재웜업 회피.
- **워커 헬스**: HEALTHCHECK(하트비트<120s)로 행 워커 감지. compose 의 `restart` 는 unhealthy 를
재시작하지 않으므로 **autoheal 컨테이너**(라벨 `autoheal=true` 감시)가 재시작 담당. k8s 는 liveness probe 로 대체.
- **잡 데드라인**: 잡 1건 300s 상한(`LPS_JOB_DEADLINE_SEC`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함.
- **잡 데드라인**: 잡 1건 300s 상한(`[WorkerConfig].job_deadline_sec`) — 크롤 행이 워커 슬롯을 영구 점유하지 못하게 함.
- **IP 선제 회전**: `[DecodoConfig].ip_request_budget`(기본 3) — IP당 요청 예산, 도달 시 차단 전에 회전(0=비활성).
`[DecodoConfig].port_cooldown_sec`(0=자동 max(sticky, 1800)) — 차단 감지된 포트 격리 시간. 포트 수를 늘리면
([DecodoConfig].port_start/end) 자동 반영 — 코드에 포트 수 하드코딩 없음. 튜닝은 `ip_session` 분석 쿼리(database.md) 참고.
**남은 배포 과제**: API 인증·레이트리밋(비용 남용 방지), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정.
- **API guard**: `[WebServerConfig].api_keys` 설정 시 `/v1/*` 전체에 X-API-Key 검증(복수 키 —
무중단 교체). 개발기는 빈값=개방 모드. **prod 체크리스트**: ① prod 서버의 config.local.toml 에
`api_keys` 채움(negodata 쪽은 `lps_api_key` 에 같은 키 — 헤더 자동 첨부) ② lps-api 외부 노출 차단
(`export LPS_API_BIND=127.0.0.1` 또는 compose `ports:` 삭제) ③ 기동 로그에서 `API guard ON` 확인.
**남은 배포 과제**: 레이트리밋(키별 요청량 제한), 다중 레플리카 시 분산 레이트리밋/프록시 IP 조정.
**비용**: 대역폭이 원가의 대부분(오픈마켓 크롤) — 같은 상품 재크롤을 줄이는 **TTL 캐시**가 다음 절감 후보.

View File

@ -64,7 +64,7 @@ API 는 asyncio(스레드 1개)라 단일 프로세스=단일 코어. uvicorn `w
| 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(max_connections=100)면 `connection_budget≈90`, 공유 PG면 40 권장(`[MainDBConfig].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**(커넥션 풀러) 도입

View File

@ -0,0 +1,17 @@
-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터.
-- bot_detection 은 차단된 세션만 남지만, 여기엔 무사 종료(budget/window 등)도 남는다.
-- 적용: psql -h <host> -U <user> -d lps_db -f migrations/2026-07-13-ip_session.sql
-- (postgres-init/init-data/init.sql 의 lps_db 섹션에도 동일 DDL 반영됨 — 신규 설치는 그쪽이 소스)
CREATE TABLE IF NOT EXISTS ip_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source VARCHAR(20) NOT NULL, -- coupang 등
proxy_port INTEGER NULL, -- 사용 포트(=IP 세션), 프록시 미사용이면 NULL
requests INTEGER NOT NULL, -- 이 IP 로 보낸 요청 수
ok_count INTEGER NOT NULL DEFAULT 0, -- 성공 검색 수
blocked_count INTEGER NOT NULL DEFAULT 0, -- 차단 감지 수
elapsed_sec INTEGER NULL, -- 세션 지속 시간(초)
end_reason VARCHAR(20) NOT NULL, -- budget/block/proxy_error/window/idle/shutdown/rotate
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 세션 종료 시각
);
CREATE INDEX IF NOT EXISTS ix_ip_session_source ON ip_session (source, created_at);

View File

@ -1,14 +1,18 @@
import asyncio
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from common.alerts import run_pool_monitor
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
from router.v1.validator.auth import configured_keys, require_api_key
import router.v1.lps.admin
import router.v1.lps.search
API_SERVER_START_TIME = GTime.UTCStr()
@ -16,9 +20,17 @@ API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
# startup: API 자신의 DB 풀 포화 감시(경량) — 대량 폴링으로 풀을 고갈시키는 주범이 API 일 수 있다.
stop = asyncio.Event()
pool_monitor = asyncio.create_task(run_pool_monitor(stop))
yield
# shutdown: DB 엔진 커넥션 풀 정리
# shutdown: 모니터 정지 후 DB 엔진 커넥션 풀 정리
stop.set()
pool_monitor.cancel()
try:
await pool_monitor
except asyncio.CancelledError:
pass
await DB_SESSION_MNG.dispose_all()
@ -75,4 +87,11 @@ async def readyz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.lps.search.router)
# guard: [WebServerConfig].api_keys 설정 시 /v1 전체에 X-API-Key 검증(개발은 빈값=개방 — auth.py 참고).
app.include_router(router.v1.lps.search.router, dependencies=[Depends(require_api_key)])
app.include_router(router.v1.lps.admin.router, dependencies=[Depends(require_api_key)])
if configured_keys():
LOG.i(f"API guard ON — X-API-Key 검증({len(configured_keys())}개 키)")
else:
LOG.w("[WebServerConfig].api_keys 비어있음 — API 개방 모드(개발용). prod 는 toml 에 키 채움 + 포트 비공개 필수")

View File

@ -0,0 +1,78 @@
"""관리자 FE 라우터 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계.
검증→service→응답만(backend 컨벤션). guard(X-API-Key)는 router.py 의 include 에서 일괄 적용.
"""
from fastapi import APIRouter, Depends, Query
from router.v1.validator.dependencies import RemoveNoneResponse
from services.admin_service import AdminService
from router.v1.lps.admin_protocol import (
Res_BotStats, Res_CostStats, Res_IpSessionStats, Res_JobList, Res_ProductList, Res_Requeue,
)
router = APIRouter(prefix="/v1/lps", tags=["LPS Admin"], responses={404: {"description": "Not found"}})
@router.get(
path="/jobs",
response_model=Res_JobList,
summary="잡 목록(관리자)",
description="최신순 잡 목록 + 전체 건수. status(PENDING/RUNNING/DONE/DEAD)·q(상품코드/상품명 부분일치) 필터.",
)
async def list_jobs(status: str | None = Query(None), q: str | None = Query(None),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
service: AdminService = Depends()):
return RemoveNoneResponse(await service.list_jobs(status, q, limit, offset))
@router.post(
path="/jobs/{job_id}/requeue",
response_model=Res_Requeue,
summary="DEAD 잡 재큐(관리자)",
description="재시도 소진으로 죽은 잡을 attempts 리셋 후 다시 대기열에 넣는다(워커 즉시 깨움). "
"같은 상품의 활성 잡이 있으면 DB_ALREADY_SAME_KEY.",
)
async def requeue_job(job_id: str, service: AdminService = Depends()):
return RemoveNoneResponse(await service.requeue(job_id))
@router.get(
path="/products",
response_model=Res_ProductList,
summary="검색 이력 상품 목록(관리자)",
description="price_history 에 이력이 있는 상품별 최신 스냅샷 + 누적 검색 수. 최근 검색순.",
)
async def list_products(q: str | None = Query(None), limit: int = Query(50, ge=1, le=200),
service: AdminService = Depends()):
return RemoveNoneResponse(await service.list_products(q, limit))
@router.get(
path="/stats/ip-sessions",
response_model=Res_IpSessionStats,
summary="IP 세션 통계(관리자)",
description="종료 사유 분포·세션당 요청 수 히스토그램·차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션.",
)
async def ip_session_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.ip_session_stats(hours))
@router.get(
path="/stats/bot",
response_model=Res_BotStats,
summary="차단(봇 감지) 통계(관리자)",
description="시간대별 차단 건수 + 최근 감지 목록(마커·포트·IP 요청순번).",
)
async def bot_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.bot_stats(hours))
@router.get(
path="/stats/cost",
response_model=Res_CostStats,
summary="검색원가 시계열(관리자)",
description="완료 잡의 metrics 를 시간별 합산 — AI vs 프록시 대역폭 비용 분해 + 평균 소요.",
)
async def cost_stats(hours: int = Query(48, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.cost_stats(hours))

View File

@ -0,0 +1,108 @@
"""관리자 FE 응답 프로토콜 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계."""
from typing import Optional
from pydantic import BaseModel, Field
from common.models.gmodel import Res_WebPacketProtocol
class JobListItem(BaseModel):
job_id: str
job_type: int
status: str = Field(description="JobStatus 이름(PENDING/RUNNING/DONE/DEAD)")
priority: int
attempts: int
max_attempts: int
product_code: Optional[str] = None
product_name: Optional[str] = None
outcome: Optional[str] = Field(None, description="found / not_found (완료 시)")
final_lowest: Optional[int] = None
cost_usd: Optional[float] = Field(None, description="검색 원가($, metrics 합)")
last_error: Optional[str] = None
created_at: str
run_started_at: Optional[str] = None
updated_at: str
class Res_JobList(Res_WebPacketProtocol):
items: list[JobListItem] = Field(default_factory=list)
total: int = 0
class Res_Requeue(Res_WebPacketProtocol):
job_id: Optional[str] = None
requeued: bool = False
class ProductItem(BaseModel):
product_code: str
display_name: Optional[str] = Field(None, description="최신 매칭 상품명(네이버 우선) — 표시용 근사값")
triggered_at: str = Field(description="마지막 검색 시각")
outcome: str
naver_lowest: Optional[int] = None
coupang_lowest: Optional[int] = None
final_lowest: Optional[int] = None
final_source: Optional[str] = None
searches: int = Field(0, description="누적 검색(이력) 수")
class Res_ProductList(Res_WebPacketProtocol):
items: list[ProductItem] = Field(default_factory=list)
class HistogramBin(BaseModel):
requests: int
count: int
class IpSessionRow(BaseModel):
source: str
proxy_port: Optional[int] = None
requests: int
ok_count: int
blocked_count: int
elapsed_sec: Optional[int] = None
end_reason: str
created_at: str
class Res_IpSessionStats(Res_WebPacketProtocol):
by_reason: dict[str, int] = Field(default_factory=dict, description="종료 사유별 세션 수")
histogram: list[HistogramBin] = Field(default_factory=list, description="세션당 요청 수 분포")
block_min_requests: Optional[int] = Field(None, description="차단 세션의 최소 요청 수 — 예산은 이보다 낮게")
sessions: list[IpSessionRow] = Field(default_factory=list, description="최근 세션 50건")
class HourlyCount(BaseModel):
bucket: str
count: int
class BotRow(BaseModel):
source: str
query: Optional[str] = None
ip_request_no: Optional[int] = None
proxy_port: Optional[int] = None
elapsed_sec: Optional[int] = None
marker: Optional[str] = None
html_len: Optional[int] = None
created_at: str
class Res_BotStats(Res_WebPacketProtocol):
hourly: list[HourlyCount] = Field(default_factory=list)
items: list[BotRow] = Field(default_factory=list, description="최근 감지 50건")
class CostBucket(BaseModel):
bucket: str = Field(description="시간(hour truncate)")
jobs: int
ai_usd: float
proxy_usd: float
total_usd: float
avg_ms: int = Field(description="검색 1건 평균 소요(ms)")
class Res_CostStats(Res_WebPacketProtocol):
buckets: list[CostBucket] = Field(default_factory=list)

View File

@ -0,0 +1,29 @@
"""API 키 guard — [WebServerConfig].api_keys 가 설정된 경우에만 /v1 라우터 전체를 보호한다.
개발(local/dev)은 키를 비워 **개방 모드**로 쓰고, prod toml 에서만 키를 채운다(협의 결정
2026-07-13). '키의 존재'가 토글이다.
- 키는 복수 허용 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거).
- 비교는 secrets.compare_digest(상수시간) — 타이밍 공격 방지.
- /healthz·/readyz 는 라우터 밖이라 guard 대상이 아니다(LB/오케스트레이터 프로브).
- prod 는 여기에 더해 lps-api 포트 비공개(내부 네트워크만)를 권장 — docs/operations.md.
"""
import secrets
from fastapi import Header, HTTPException
from config.server_configs import web_server_config
def configured_keys() -> set[str]:
"""유효 API 키 집합. 매 호출 config 를 읽는다 — 테스트에서 monkeypatch 로 on/off 전환 가능."""
return {k.strip() for k in web_server_config.api_keys if k.strip()}
async def require_api_key(x_api_key: str | None = Header(None, alias="X-API-Key")):
"""/v1 공통 의존성. 키 미설정=개방 모드(무검증), 설정 시 X-API-Key 불일치는 401."""
keys = configured_keys()
if not keys:
return
if not x_api_key or not any(secrets.compare_digest(x_api_key, k) for k in keys):
raise HTTPException(status_code=401, detail="invalid or missing X-API-Key")

40
lps/run_docker.sh Executable file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# LPS Docker 실행(대화형) — lps-api/lps-worker/lps-admin 을 관리한다.
# 환경 구분 없음(도메인 backend·negodata·agent 와 동일): 항상 config.local.toml.
# 어느 서버든 그 서버의 config.local.toml 값으로 뜬다 — prod 서버엔 prod 값을 채워두면 그만.
# 호스트(비도커) 실행은 run_local_server.sh / run_local_worker.sh 를 사용.
set -euo pipefail
cd "$(dirname "$0")/.." # 리포 루트(docker-compose.yml 위치)
echo "── LPS Docker 실행 ──"
CFG="lps/config/config.local.toml"
if [[ ! -f "$CFG" ]]; then
echo "[warn] $CFG 가 없습니다 (시크릿 포함, 미커밋 — 서버마다 직접 준비)."
read -rp "example 을 복사해 생성할까요? [Y/n] " yn
if [[ "${yn:-Y}" =~ ^[Yy] ]]; then
cp "lps/config/config.local.toml.example" "$CFG"
echo "[ok] $CFG 생성 — DB 접속·API 키 등 값을 채운 뒤 다시 실행하세요."
fi
exit 0
fi
# prod 서버 안전 점검: guard 키가 비어 있으면 API 가 개방 모드로 뜬다 — 명시적 확인.
if grep -qE '^\s*api_keys\s*=\s*\[\s*\]' "$CFG"; then
echo "[warn] [WebServerConfig].api_keys 가 비어 있습니다 → API 개방 모드(무인증). prod 라면 키를 채우세요."
read -rp "그래도 계속할까요? [y/N] " go
[[ "${go:-N}" =~ ^[Yy] ]] || { echo "중단합니다."; exit 1; }
fi
# 운영(리버스프록시 뒤)에서 API 를 외부에 열지 않으려면 실행 전에: export LPS_API_BIND=127.0.0.1
echo ""
echo "설정: $CFG · API 바인드: ${LPS_API_BIND:-0.0.0.0}"
read -rp "동작 선택 [1] 시작/재빌드(기본) [2] 재시작 [3] 중지 [4] 로그 : " act
case "${act:-1}" in
1) docker compose up -d --build lps-api lps-worker lps-admin autoheal
echo "[ok] 기동 — admin: http://localhost:3400 · 로그의 'API guard ON/개방 모드' · 'DECODO 프리플라이트' 확인: docker compose logs -f lps-api lps-worker lps-admin" ;;
2) docker compose restart lps-api lps-worker lps-admin ;;
3) docker compose stop lps-api lps-worker lps-admin ;;
4) docker compose logs -f lps-api lps-worker lps-admin ;;
*) echo "[error] 알 수 없는 선택: $act"; exit 1 ;;
esac

View File

@ -51,15 +51,11 @@ export APP_ENV=local
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=...)' 으로 실효 풀 확인"
# 커넥션 예산은 config.local.toml [MainDBConfig].connection_budget 이 소스(2026-07-13 toml 단일화).
read -rp "프로세스 수 [엔터=toml 설정값] (CPU ${cpu_count}코어, 부하테스트 벤치는 4): " pc
[[ -n "$pc" ]] && export PROCESS_COUNT="$pc"
echo "[run] web_main.py → http://localhost:$PORT/docs (프로세스 ${pc:-toml 설정값}개)"
echo " 기동 로그의 'DB Pool : ...' 으로 실효 풀 확인"
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 ;;

View File

@ -36,27 +36,17 @@ if pgrep -f worker_main.py >/dev/null 2>&1; then
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:-}"
# 4) 동시성 입력 — 실행 시 임시 override(엔터=toml [WorkerConfig].concurrency 사용).
# 프로필·폴백·데드라인 등 나머지 설정은 config.local.toml [WorkerConfig] 가 소스(2026-07-13 toml 단일화).
echo "── 워커 설정 (프로필·폴백 등은 config.local.toml [WorkerConfig]에서) ──"
read -rp "동시 검색 수 [엔터=toml 설정값] (로컬 권장 2~3): " CONC
export APP_ENV=local
export WORKER_CONCURRENCY="$CONC"
export LPS_PROFILE_DIR="$PROFILE"
[[ -n "$CONC" ]] && export WORKER_CONCURRENCY="$CONC"
export PYTHONUNBUFFERED=1 # 로그 실시간 출력
echo ""
echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE · 폴백=${LPS_FALLBACKS:-OFF})"
echo "[run] worker_main.py (동시성=${CONC:-toml 설정값})"
echo " 기동 로그의 'DECODO 프리플라이트 OK — egress IP ...' / 'AI: ON/OFF' 확인"
echo " 중단: Ctrl+C"
echo ""

View File

@ -0,0 +1,116 @@
"""관리자 FE 도메인 로직 — 조회 중심 + 필수 액션(DEAD 재큐)만.
설정 변경(예산·임계 등)은 FE 에 두지 않는다 — 설정은 toml 단일 소스(재시작 반영) 원칙.
"""
import uuid
from fastapi import Depends
from sqlalchemy.exc import IntegrityError
from common.enums import ErrorType, JobStatus
from crud.bot_detection import BotDetectionLog
from crud.ip_session import IpSessionLog
from crud.job_crud import JobQueue
from crud.price_history import PriceHistory
from router.v1.lps.admin_protocol import (
BotRow,
CostBucket,
HistogramBin,
HourlyCount,
IpSessionRow,
JobListItem,
ProductItem,
Res_BotStats,
Res_CostStats,
Res_IpSessionStats,
Res_JobList,
Res_ProductList,
Res_Requeue,
)
_STATUS_BY_NAME = {js.name: js.value for js in JobStatus}
class AdminService:
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory),
ip_log: IpSessionLog = Depends(IpSessionLog), bot_log: BotDetectionLog = Depends(BotDetectionLog)):
self.queue = queue
self.history = history
self.ip_log = ip_log
self.bot_log = bot_log
async def list_jobs(self, status: str | None, q: str | None, limit: int, offset: int) -> Res_JobList:
res = Res_JobList()
status_code = _STATUS_BY_NAME.get(status.upper()) if status else None
if status and status_code is None:
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) # 알 수 없는 상태 이름
return res
rows, total = await self.queue.list_jobs(status_code, q, limit, offset)
res.total = total
res.items = [JobListItem(
job_id=r["job_id"], job_type=r["job_type"], status=JobStatus(r["status"]).name,
priority=r["priority"], attempts=r["attempts"], max_attempts=r["max_attempts"],
product_code=r.get("product_code"), product_name=r.get("product_name"),
outcome=r.get("outcome"), final_lowest=r.get("final_lowest"), cost_usd=r.get("cost_usd"),
last_error=r.get("last_error"),
created_at=r["created_at"].isoformat(timespec="seconds"),
run_started_at=r["run_started_at"].isoformat(timespec="seconds") if r.get("run_started_at") else None,
updated_at=r["updated_at"].isoformat(timespec="seconds"),
) for r in rows]
return res
async def requeue(self, job_id: str) -> Res_Requeue:
res = Res_Requeue(job_id=job_id)
try:
uuid.UUID(job_id)
except (ValueError, TypeError):
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
return res
try:
requeued = await self.queue.requeue(job_id)
except IntegrityError:
# 같은 상품의 활성 잡(PENDING/RUNNING)이 이미 있음 — 부분 유니크(dedupe) 위반
res.result.SetResult(ErrorType.DB_ALREADY_SAME_KEY)
return res
if requeued is None: # 없거나 DEAD 가 아님
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
return res
res.requeued = True
return res
async def list_products(self, q: str | None, limit: int) -> Res_ProductList:
res = Res_ProductList()
res.items = [ProductItem(
product_code=r["product_code"], display_name=r.get("display_name"),
triggered_at=r["triggered_at"].isoformat(timespec="seconds"), outcome=r["outcome"],
naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"),
final_lowest=r.get("final_lowest"), final_source=r.get("final_source"),
searches=int(r.get("searches") or 0),
) for r in await self.history.list_products(q, limit)]
return res
async def ip_session_stats(self, hours: int) -> Res_IpSessionStats:
res = Res_IpSessionStats()
st = await self.ip_log.admin_stats(hours)
res.by_reason = st["by_reason"]
res.histogram = [HistogramBin(**b) for b in st["histogram"]]
res.block_min_requests = st["block_min_requests"]
res.sessions = [IpSessionRow(
**{**s, "created_at": s["created_at"].isoformat(timespec="seconds")}
) for s in st["sessions"]]
return res
async def bot_stats(self, hours: int) -> Res_BotStats:
res = Res_BotStats()
st = await self.bot_log.admin_stats(hours)
res.hourly = [HourlyCount(bucket=h["bucket"], count=h["count"]) for h in st["hourly"]]
res.items = [BotRow(
**{**b, "created_at": b["created_at"].isoformat(timespec="seconds")}
) for b in st["items"]]
return res
async def cost_stats(self, hours: int) -> Res_CostStats:
res = Res_CostStats()
res.buckets = [CostBucket(**b) for b in await self.queue.cost_buckets(hours)]
return res

View File

@ -8,6 +8,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import ErrorType, JobStatus, JobType
from crud.job_crud import JobQueue
from crud.price_history import PriceHistory
@ -74,9 +75,11 @@ class LpsService:
return res
async def ops(self) -> dict:
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수."""
"""운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수 + DB 풀 사용률."""
snap = await self.queue.ops()
snap["blocks_1h"] = await self.bot_log.recent_count(60)
pool = DB_SESSION_MNG.pool_status() # API 프로세스 자신의 풀(워커 풀은 워커 ops-monitor 가 감시)
snap["pool_checked_out"], snap["pool_capacity"], snap["pool_pct"] = pool["checked_out"], pool["capacity"], pool["pct"]
return snap
async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory:

View File

@ -14,13 +14,13 @@ 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 config.server_configs import decodo_config, worker_config
from services.search.contract import SearchAdapter, NormalizedProduct, AdapterError, AdapterHealth
from services.search.rate_limiter import RateLimiter
@ -28,10 +28,10 @@ from services.search.rate_limiter import RateLimiter
# 오픈마켓(ESM/11번가)은 CSS/JS 를 막으면 렌더/챌린지가 깨져 이미지·미디어·폰트만 막는다(어댑터에서 override).
_BLOCKED_RESOURCES = {"image", "media", "font", "stylesheet"}
# 브라우저 실행 대상(env override): 로컬 Mac=실제 Chrome(channel=chrome), 컨테이너=시스템 chromium(executable_path).
# 브라우저 실행 대상([WorkerConfig]): 로컬 Mac=실제 Chrome(channel), 컨테이너=시스템 chromium(executable).
# headless 는 안티봇에 탐지되므로 서버에선 Xvfb(가상 디스플레이)로 headful 실행한다(headless 실측 실패).
_CHROME_CHANNEL = os.environ.get("LPS_CHROME_CHANNEL", "chrome")
_CHROME_EXECUTABLE = os.environ.get("LPS_CHROME_EXECUTABLE") or None
_CHROME_CHANNEL = worker_config.chrome_channel
_CHROME_EXECUTABLE = worker_config.chrome_executable or None
def detect_block(html: str, product_count: int, markers: tuple, min_len: int) -> str | None:
@ -73,7 +73,8 @@ class BrowserSearchAdapter(SearchAdapter):
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):
proxy=None, block_resources: bool | None = None, on_detect=None, max_block_retries: int = 1,
ip_request_budget: int | None = None, on_session_end=None):
self._headless = headless
self._user_data_dir = user_data_dir or f"/tmp/lps_{self.source}_profile"
self._rl = rate_limiter or RateLimiter()
@ -82,6 +83,10 @@ class BrowserSearchAdapter(SearchAdapter):
self._block_active = self._block_resources # 요청별 실제 차단 여부(_blocking_now 로 갱신)
self._on_detect = on_detect # async def(event: dict) — 감지 영속화(선택)
self._max_block_retries = max_block_retries
# IP(포트 세션)당 요청 예산([DecodoConfig].ip_request_budget) — 도달하면 차단당하기 **전에**
# 선제 회전해 IP 평판을 보존한다. 실측상 5회 부근 차단 이력 → 기본 3. 0=비활성(시간창 회전만).
self._ip_budget = decodo_config.ip_request_budget if ip_request_budget is None else ip_request_budget
self._on_session_end = on_session_end # async def(event: dict) — IP 세션 종료 기록(선택, 상한 튜닝 데이터)
self._pw = None
self._ctx = None
self._launched_at = 0.0
@ -91,6 +96,9 @@ class BrowserSearchAdapter(SearchAdapter):
self._lock = asyncio.Lock()
self._ok = 0
self._blocked = 0
self._sess_ok = 0 # 현재 IP 세션의 성공/차단(세션 종료 기록용, 재기동 시 리셋)
self._sess_blocked = 0
self._end_reason = None # 이번 세션이 끝나는 이유(budget/block/proxy_error/window/idle/shutdown)
self._last_used = 0.0 # 마지막 검색 시각(monotonic) — 유휴 브라우저 정리 판단용
self._cdp = None # CDP 세션(실제 네트워크 바이트 계측용). 미지원 시 None → DOM 크기 폴백
self._net_bytes = 0 # 현재 검색의 실제 전송 바이트(encodedDataLength 누적)
@ -143,6 +151,8 @@ class BrowserSearchAdapter(SearchAdapter):
if self._ctx is not None:
if self._recycle_due():
LOG.d(f"[{self.source}] 브라우저 재기동(IP 회전)")
if self._end_reason is None: # force 가 아닌 시간창 만료 재기동
self._end_reason = "window"
await self._close_ctx()
else:
return
@ -163,16 +173,35 @@ class BrowserSearchAdapter(SearchAdapter):
await self._ctx.route("**/*", self._route)
self._launched_at = time.monotonic()
self._ip_requests = 0
self._sess_ok = self._sess_blocked = 0
self._end_reason = None
self._force_recycle = False
async def _close_ctx(self):
self._cdp = None # 컨텍스트와 함께 CDP 세션도 죽음 → 다음 검색 때 재부착
if self._ctx is not None:
await self._record_session_end()
try:
await self._ctx.close()
finally:
self._ctx = None
async def _record_session_end(self):
"""IP 세션 종료 1건 기록 — '이 IP 로 몇 번 요청하고 어떻게 끝났나'. 예산(상한) 튜닝의 원천 데이터.
요청이 없던 세션(유휴 정리 등)은 노이즈라 기록하지 않는다. 기록 실패가 검색을 막지 않는다."""
if self._on_session_end is None or self._ip_requests == 0:
self._end_reason = None
return
event = {"source": self.source, "proxy_port": self._current_port,
"requests": self._ip_requests, "ok_count": self._sess_ok, "blocked_count": self._sess_blocked,
"elapsed_sec": int(time.monotonic() - self._launched_at),
"end_reason": self._end_reason or "window"}
self._end_reason = None
try:
await self._on_session_end(event)
except Exception as ex:
LOG.e_no_callstack(f"[{self.source}] IP 세션 기록 실패(무시): {ex}")
def _add_net(self, event):
"""CDP Network.loadingFinished 콜백 — 실제 전송 바이트(encodedDataLength) 누적."""
try:
@ -196,12 +225,18 @@ class BrowserSearchAdapter(SearchAdapter):
"""이 어댑터가 프록시(DECODO)를 경유하는지 — 대역폭 비용 귀속용."""
return bool(self._proxy and self._proxy.enabled)
def _rotate_ip(self, reason: str):
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동."""
def _budget_reached(self) -> bool:
"""현재 IP 로 요청 예산을 소진했는지(선제 회전 트리거). 프록시 미사용·예산 0(비활성)이면 False."""
return self.uses_proxy and self._ip_budget > 0 and self._ip_requests >= self._ip_budget
def _rotate_ip(self, reason: str, kind: str = "rotate", warn: bool = True):
"""즉시 다음 IP(포트)로 회전 예약 + 다음 _ensure_browser 에서 브라우저 재기동.
kind 는 세션 종료 사유로 기록된다(budget=선제/block=차단/proxy_error=포트사망)."""
if self._proxy and self._proxy.enabled:
self._proxy.rotate()
self._force_recycle = True
LOG.w(f"[{self.source}] IP 회전 — {reason}")
self._end_reason = kind
(LOG.w if warn else LOG.i)(f"[{self.source}] IP 회전 — {reason}")
# ---- 검색(프록시 전송오류·봇 감지 → IP 회전 인라인 재시도) --------
async def search(self, query: str, limit: int = 40) -> list[NormalizedProduct]:
@ -210,6 +245,10 @@ class BrowserSearchAdapter(SearchAdapter):
proxy_retries, block_retries = self.max_proxy_retries, self._max_block_retries
while True:
await self._rl.wait()
# 예산 도달 → 차단당하기 전에 선제 회전. 이 포트는 불탄 게 아니라 쿨다운 없이
# 로테이션 복귀 시 재사용된다(IP 평판 보존이 목적).
if self._budget_reached():
self._rotate_ip(f"요청예산 {self._ip_budget}회 도달 — 선제 회전", kind="budget", warn=False)
await self._ensure_browser()
self._ip_requests += 1
page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
@ -225,8 +264,10 @@ class BrowserSearchAdapter(SearchAdapter):
# 프록시 전송 실패(포트/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}")
self._proxy.mark_burned(self._current_port) # 죽은 포트 — 쿨다운 뒤 복귀(sticky 만료로 새 IP)
self._rotate_ip(f"프록시 전송오류({type(ex).__name__}) 재시도 {self.max_proxy_retries - proxy_retries}/{self.max_proxy_retries}", kind="proxy_error")
continue
self._note_result(False)
raise AdapterError(f"{self.source} 검색 실패: {ex}", source=self.source) from ex
# 실제 프록시 전송 바이트(CDP encodedDataLength) — 미지원 시 DOM 크기 폴백
@ -234,6 +275,8 @@ class BrowserSearchAdapter(SearchAdapter):
products = self._parse(html)
if products:
self._ok += 1
self._sess_ok += 1
self._note_result(True)
LOG.d(f"[{self.source}] query={query!r} → {len(products)}건 (limit {limit}, ip_req#{self._ip_requests})")
return products[:limit]
@ -241,13 +284,19 @@ class BrowserSearchAdapter(SearchAdapter):
blocked = marker is not None
self._blocked += 1
if blocked:
self._sess_blocked += 1
await self._report_detection(query, marker, len(html))
if self.uses_proxy:
self._proxy.mark_burned(self._current_port) # 불탄 포트 — 쿨다운 격리(로테이션이 건너뜀)
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}")
self._rotate_ip(f"봇 감지 재시도 {self._max_block_retries - block_retries}/{self._max_block_retries}", kind="block")
continue
if blocked: # 재시도 소진/비활성 — 불탄 포트로 다음 검색을 하지 않도록 회전만 예약하고 포기
self._rotate_ip("봇 감지 — 다음 검색은 새 IP", kind="block")
self._note_result(False)
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):
@ -279,12 +328,14 @@ class BrowserSearchAdapter(SearchAdapter):
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 초과 → 브라우저 정리(다음 검색 때 재기동)")
if self._end_reason is None:
self._end_reason = "idle"
await self._close_ctx()
async def close(self):
if self._ctx is not None:
await self._ctx.close()
self._ctx = None
if self._end_reason is None:
self._end_reason = "shutdown"
await self._close_ctx()
if self._pw is not None:
await self._pw.stop()
self._pw = None

View File

@ -5,7 +5,9 @@
새 소스는 SearchAdapter 를 구현하기만 하면 코어 변경 없이 붙는다(트레드밀 격리).
"""
import time
from abc import ABC, abstractmethod
from collections import deque
from typing import Optional
from pydantic import BaseModel, Field
@ -59,3 +61,24 @@ class SearchAdapter(ABC):
async def health(self) -> AdapterHealth:
"""기본 건강도. 어댑터가 관측 지표를 축적하면 override."""
return AdapterHealth(source=self.source, ok=True)
# ---- 시간 윈도우 성공/실패 카운터(장기 실패 알림용) ------------------
# 기존 _ok/_blocked 는 기동 후 누적이라 '최근 30분 성공 0건' 같은 장기 실패를 못 본다.
# 어댑터의 search 성공/실패 지점에서 _note_result 를 부르면 ops-monitor 가 recent_stats 로 읽는다.
# (lazy init — 서브클래스가 super().__init__ 을 부르지 않아도 동작)
def _note_result(self, ok: bool):
ev = getattr(self, "_win_events", None)
if ev is None:
ev = self._win_events = deque(maxlen=512)
ev.append((time.monotonic(), ok))
def recent_stats(self, window_sec: float = 1800.0) -> tuple[int, int]:
"""최근 window_sec 내 (시도 수, 성공 수)."""
now = time.monotonic()
tries = ok = 0
for t, s in getattr(self, "_win_events", ()):
if now - t <= window_sec:
tries += 1
ok += s
return tries, ok

View File

@ -50,24 +50,29 @@ class NaverAdapter(SearchAdapter):
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
try:
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
except Exception:
self._note_result(False) # 장기 실패 알림용 윈도우 카운터(쿼터 소진·네트워크 포함)
raise
products = transform_items(collected, self.source)
self._ok += 1
self._note_result(True)
LOG.d(f"[naver] query={query!r} → {len(products)}건 (limit {limit})")
return products[:limit]

View File

@ -28,24 +28,51 @@ class DecodoProxy:
self.port_end = cfg.port_end
self.session_minutes = cfg.session_minutes or 10
self._rotate_offset = 0 # 봇 감지 등으로 '즉시 회전'이 필요할 때 증가
# 불탄(차단 감지된) 포트 격리 시간([DecodoConfig].port_cooldown_sec). sticky 만료(session_minutes)
# 이상이어야 쿨다운 복귀 시 같은 포트라도 사실상 새 IP 가 배정된다. 0=자동 max(sticky, 30분).
self.cooldown_sec = getattr(cfg, "port_cooldown_sec", 0) or max(self.session_minutes * 60, 1800)
self._burned: dict[int, float] = {} # port → 쿨다운 만료 시각(monotonic)
@property
def enabled(self) -> bool:
return all([self.host, self.username, self.password, self.port_start, self.port_end])
def rotate(self):
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지 시 호출."""
"""시간창과 무관하게 즉시 다음 포트(=새 IP)로 회전. 봇 감지·예산 도달 시 호출."""
self._rotate_offset += 1
def seed_offset(self, k: int):
"""워커별 시작 포트 분산용 — 동시 워커가 같은 포트(=같은 IP)를 쓰지 않도록 시작점을 벌린다."""
self._rotate_offset = k
def mark_burned(self, port: int | None, cooldown_sec: float | None = None):
"""차단 감지된 포트를 쿨다운 격리 — _port() 가 만료 전까지 건너뛴다.
선제(예산) 회전된 포트는 부르지 않는다 — 불탄 게 아니므로 로테이션 복귀 시 재사용."""
if port is None:
return
self._burned[port] = time.monotonic() + (cooldown_sec if cooldown_sec is not None else self.cooldown_sec)
LOG.i(f"[proxy] 포트 {port} 쿨다운 {int(cooldown_sec or self.cooldown_sec)}s — 활성 {self.available_ports()}/{self.port_end - self.port_start + 1}")
def available_ports(self) -> int:
"""쿨다운 중이 아닌 포트 수(관측·알림용)."""
self._prune_burned()
return (self.port_end - self.port_start + 1) - len(self._burned)
def _prune_burned(self):
now = time.monotonic()
self._burned = {p: t for p, t in self._burned.items() if t > now}
def _port(self) -> int:
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 IP, rotate()나 창 변화 시 다음 IP."""
"""시간창 + 수동 오프셋 기반 포트 선택. 창 안에선 동일 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)
self._prune_burned()
for k in range(n):
port = self.port_start + ((bucket + self._rotate_offset + k) % n)
if port not in self._burned:
return port
return min(self._burned, key=self._burned.get)
@property
def current_port(self):

139
lps/tests/test_admin_api.py Normal file
View File

@ -0,0 +1,139 @@
"""관리자 FE API 테스트 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계 (실 lps_db)."""
import pytest_asyncio
from sqlalchemy import text
from common.enums import JobType
from crud.job_crud import JobQueue
@pytest_asyncio.fixture
async def clean_all(db_engine):
async with db_engine.begin() as conn:
for t in ("job", "price_history", "ip_session", "bot_detection"):
await conn.execute(text(f"TRUNCATE {t}"))
return db_engine
@pytest_asyncio.fixture
async def q(clean_all):
return JobQueue()
# ---- 잡 목록 --------------------------------------------------------------
async def test_jobs_list_with_filters(client, q):
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1", "product_name": "맥심 커피"})
await q.enqueue(JobType.SEARCH.value, {"product_code": "B2", "product_name": "생수"})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1", {"outcome": "found", "lowest": {"price": 12000},
"metrics": {"cost": {"total_usd": 0.01}}})
r = await client.get("/v1/lps/jobs")
j = r.json()
assert r.status_code == 200 and j["total"] == 2
r = await client.get("/v1/lps/jobs", params={"status": "DONE"})
j = r.json()
assert j["total"] == 1
assert j["items"][0]["outcome"] == "found" and j["items"][0]["final_lowest"] == 12000
assert j["items"][0]["cost_usd"] == 0.01
r = await client.get("/v1/lps/jobs", params={"q": "맥심"})
assert r.json()["total"] == 1
async def test_jobs_list_unknown_status(client, q):
r = await client.get("/v1/lps/jobs", params={"status": "NOPE"})
assert r.json()["result"]["success"] is False
# ---- 재큐 ------------------------------------------------------------------
async def test_requeue_dead_job(client, q):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
await q.claim("w1")
await q.fail(jid, "w1", "boom", backoff_sec=0) # 1/1 → DEAD
r = await client.post(f"/v1/lps/jobs/{jid}/requeue")
assert r.json()["requeued"] is True
assert (await q.counts())["PENDING"] == 1 # DEAD → PENDING
async def test_requeue_rejects_non_dead_and_missing(client, q):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"})
r = await client.post(f"/v1/lps/jobs/{jid}/requeue") # PENDING — 재큐 대상 아님
assert r.json()["result"]["success"] is False
r = await client.post("/v1/lps/jobs/not-a-uuid/requeue")
assert r.json()["result"]["success"] is False
async def test_requeue_blocked_by_active_duplicate(client, q):
dead = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
await q.claim("w1")
await q.fail(dead, "w1", "boom", backoff_sec=0)
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, dedupe_key="search-A1") # 활성 중복 생성
r = await client.post(f"/v1/lps/jobs/{dead}/requeue")
assert r.json()["requeued"] is False
assert r.json()["result"]["success"] is False # DB_ALREADY_SAME_KEY
# ---- 상품 목록 --------------------------------------------------------------
async def test_products_list_latest_snapshot(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO price_history (product_code, outcome, naver_lowest, final_lowest, naver_name, triggered_at)
VALUES ('P1', 'found', 1000, 900, '커피 320개입', now() - interval '2 hour'),
('P1', 'found', 1100, 950, '커피 320개입', now() - interval '1 hour'),
('P2', 'not_found', NULL, NULL, NULL, now())
"""))
r = await client.get("/v1/lps/products")
items = r.json()["items"]
assert [i["product_code"] for i in items] == ["P2", "P1"] # 최근 검색순
p1 = items[1]
assert p1["searches"] == 2 and p1["final_lowest"] == 950 # 최신 스냅샷 + 누적 횟수
r = await client.get("/v1/lps/products", params={"q": "P1"})
assert len(r.json()["items"]) == 1
# ---- 통계 3종 ---------------------------------------------------------------
async def test_ip_session_stats(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, end_reason)
VALUES ('coupang', 10001, 3, 3, 0, 'budget'),
('coupang', 10002, 3, 3, 0, 'budget'),
('coupang', 10003, 5, 4, 1, 'block')
"""))
r = await client.get("/v1/lps/stats/ip-sessions")
j = r.json()
assert j["by_reason"] == {"budget": 2, "block": 1}
assert j["block_min_requests"] == 5 # 예산 튜닝 기준선
assert {"requests": 3, "count": 2} in j["histogram"]
assert len(j["sessions"]) == 3
async def test_bot_stats(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO bot_detection (source, query, ip_request_no, proxy_port, marker)
VALUES ('coupang', '생수', 4, 10001, '/akam/')
"""))
r = await client.get("/v1/lps/stats/bot")
j = r.json()
assert len(j["items"]) == 1 and j["items"][0]["marker"] == "/akam/"
assert sum(h["count"] for h in j["hourly"]) == 1
async def test_cost_stats(client, q):
for cost in (0.01, 0.02):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": f"C{cost}"})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1",
{"metrics": {"cost": {"ai_usd": cost / 2, "proxy_usd": cost / 2, "total_usd": cost},
"duration_ms": 15000}})
r = await client.get("/v1/lps/stats/cost")
buckets = r.json()["buckets"]
assert sum(b["total_usd"] for b in buckets) == 0.03
assert sum(b["jobs"] for b in buckets) == 2
assert buckets[0]["avg_ms"] == 15000

127
lps/tests/test_alerts.py Normal file
View File

@ -0,0 +1,127 @@
"""AlertManager 테스트 — 발화·쿨다운(스팸 방지)·회복 알림 (sender/clock 주입, 네트워크·대기 없음)."""
from common.alerts import AlertManager
from common.database.db_session_manager import DB_SESSION_MNG
from services.search.naver.adapter import NaverAdapter
class _Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
def _mgr(cooldown=1800):
sent = []
async def sender(text):
sent.append(text)
clock = _Clock()
return AlertManager(origin="test", cooldown_sec=cooldown, sender=sender, clock=clock), sent, clock
async def test_fires_once_on_activation():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "DEAD 1h=25")
await mgr.check("dead", True, "DEAD 1h=26") # 조건 지속 — 쿨다운 안이라 재발송 없음
assert len(sent) == 1 and "DEAD 1h=25" in sent[0]
assert mgr.is_active("dead")
async def test_refires_after_cooldown():
mgr, sent, clock = _mgr(cooldown=1800)
await mgr.check("dead", True, "m1")
clock.t += 1801 # 쿨다운 경과 — 리마인드 1회
await mgr.check("dead", True, "m2")
assert len(sent) == 2 and "m2" in sent[1]
async def test_recovery_notice_once():
mgr, sent, _ = _mgr()
await mgr.check("blocks", True, "차단 1h=90")
await mgr.check("blocks", False, "차단 1h=3") # 해소 알림 1회
await mgr.check("blocks", False, "차단 1h=0") # 이미 비활성 — 무발송
assert len(sent) == 2
assert "해소" in sent[1]
assert not mgr.is_active("blocks")
async def test_inactive_rule_never_fires():
mgr, sent, _ = _mgr()
await mgr.check("queue_lag", False, "큐지연=3s")
assert sent == []
async def test_rules_are_independent():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "a")
await mgr.check("db_pool", True, "b") # 다른 키 — 각자 발화
assert len(sent) == 2
async def test_sender_failure_does_not_raise():
async def boom(text):
raise RuntimeError("webhook down")
mgr = AlertManager(origin="test", cooldown_sec=10, sender=boom)
try:
await mgr.check("dead", True, "m")
except RuntimeError:
# sender 주입 시 예외는 호출부(모니터 루프의 try)가 처리 — 여기서는 전파돼도 루프가 삼킨다
pass
# ---- 윈도우 성공/실패 카운터(소스별 장기 실패 룰의 데이터) ----------------
def test_recent_stats_counts_within_window():
ad = NaverAdapter(keys=[("id", "sec")])
ad._note_result(True)
ad._note_result(False)
ad._note_result(False)
assert ad.recent_stats(1800) == (3, 1)
assert ad.recent_stats(0) == (0, 0) # 창 밖이면 미집계
def test_recent_stats_empty_adapter():
ad = NaverAdapter(keys=[("id", "sec")])
assert ad.recent_stats(1800) == (0, 0) # lazy init — 기록 전에도 안전
# ---- DB 풀 사용 현황 ------------------------------------------------------
def test_pool_status_shape():
st = DB_SESSION_MNG.pool_status()
assert set(st) == {"checked_out", "capacity", "pct"}
assert st["capacity"] > 0 # R/W 2엔진 × (pool+overflow)
assert 0 <= st["pct"] <= 100
# ---- 가용 프록시 포트 현황(포트 고갈 룰의 데이터) --------------------------
def test_proxy_ports_snapshot_min_across_workers():
from config.config_models import DecodoConfig
from services.search.proxy import DecodoProxy
from worker_main import _proxy_ports_snapshot
def _proxy():
return DecodoProxy(DecodoConfig(host="h", username="u", password="p",
port_start=10001, port_end=10010, session_minutes=10))
class _Ad:
def __init__(self, proxy):
self._proxy = proxy
p1, p2 = _proxy(), _proxy()
p2.mark_burned(10001)
p2.mark_burned(10002)
avail, total = _proxy_ports_snapshot([_Ad(p1), _Ad(p2), object()]) # 프록시 없는 어댑터 혼재 OK
assert (avail, total) == (8, 10) # 가장 소진된 워커(p2) 기준
def test_proxy_ports_snapshot_none_without_proxy():
from worker_main import _proxy_ports_snapshot
assert _proxy_ports_snapshot([object()]) is None
assert _proxy_ports_snapshot(None) is None

View File

@ -0,0 +1,45 @@
"""API guard 테스트 — [WebServerConfig].api_keys 설정 시에만 /v1 에 X-API-Key 검증(개발=빈값=개방 모드).
auth.configured_keys 가 매 요청 config 를 읽으므로 monkeypatch.setattr 만으로 on/off 를 전환한다
(앱 재기동 불필요).
"""
import pytest
from config.server_configs import web_server_config
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setattr(web_server_config, "api_keys", ["k1", "k2"])
async def test_open_mode_without_keys(client, monkeypatch):
monkeypatch.setattr(web_server_config, "api_keys", [])
r = await client.get("/v1/lps/queue/stats") # 개방 모드 — 헤더 없이 통과
assert r.status_code == 200
async def test_guarded_rejects_missing_header(client, guarded):
r = await client.get("/v1/lps/queue/stats")
assert r.status_code == 401
async def test_guarded_rejects_wrong_key(client, guarded):
r = await client.get("/v1/lps/queue/stats", headers={"X-API-Key": "nope"})
assert r.status_code == 401
async def test_guarded_accepts_any_configured_key(client, guarded):
for key in ("k1", "k2"): # 복수 키 — 무중단 키 교체용
r = await client.get("/v1/lps/queue/stats", headers={"X-API-Key": key})
assert r.status_code == 200
async def test_guard_covers_post_search(client, guarded):
r = await client.post("/v1/lps/search", json={"data": []})
assert r.status_code == 401 # enqueue(비용 발생 경로)도 보호
async def test_healthz_stays_open(client, guarded):
assert (await client.get("/healthz")).status_code == 200 # LB 프로브는 guard 밖

View File

@ -49,8 +49,9 @@ class _MockCtx:
class _MockProxy:
enabled = True
session_minutes = 10
def __init__(self): self.rotations = 0
def __init__(self): self.rotations, self.burned = 0, []
def rotate(self): self.rotations += 1
def mark_burned(self, port, cooldown_sec=None): self.burned.append(port)
def playwright_proxy(self): return None
@property
def current_port(self): return 10001
@ -70,6 +71,7 @@ class _MockAdapter(BrowserSearchAdapter):
self._ctx = _MockCtx(self._page)
self._force_recycle = False
self._ip_requests = 0
self._current_port = self._proxy.current_port if self._proxy else None # 실제 _ensure_browser 와 동일
async def _ensure_net_meter(self, page): pass # CDP 없음 → last_bytes=DOM 크기
async def _wait_ready(self, page): pass
@ -110,11 +112,12 @@ async def test_non_proxy_goto_error_raises_no_rotation():
async def test_persistent_block_exhausts_and_raises_blocked():
# 차단 2회 연속(max_block_retries=1) → 회전 1회 후 소진 → blocked=True 로 실패
# 차단 2회 연속(max_block_retries=1) → 재시도 회전 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
assert ei.value.blocked is True and ad._proxy.rotations == 2
assert ad._proxy.burned == [10001, 10001] # 차단마다 해당 포트 쿨다운 격리
# ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ──────

View File

@ -0,0 +1,136 @@
"""IP 선제 로테이션 테스트 — 요청 예산 판정·세션 종료 기록(브라우저 불필요) + CRUD(실 lps_db)."""
import pytest_asyncio
from sqlalchemy import text
from config.config_models import DecodoConfig
from crud.ip_session import IpSessionLog
from services.search.coupang.adapter import CoupangAdapter
from services.search.proxy import DecodoProxy
def _proxy():
return DecodoProxy(DecodoConfig(host="gate.decodo.com", username="u", password="p",
port_start=10001, port_end=10010, session_minutes=10))
def _adapter(**kw):
# __init__ 은 브라우저를 띄우지 않는다 — 예산/세션 부기 로직만 검증
return CoupangAdapter(headless=True, user_data_dir="/tmp/lps_test_profile", **kw)
# ---- 요청 예산(선제 회전) 판정 ------------------------------------------
def test_budget_reached_only_with_proxy():
ad = _adapter(ip_request_budget=3)
ad._ip_requests = 3
assert ad._budget_reached() is False # 프록시 미사용 — 예산 개념 없음
ad._proxy = _proxy()
assert ad._budget_reached() is True
def test_budget_zero_disables_preemptive_rotation():
ad = _adapter(ip_request_budget=0, proxy=_proxy())
ad._ip_requests = 999
assert ad._budget_reached() is False
def test_budget_not_reached_below_threshold():
ad = _adapter(ip_request_budget=3, proxy=_proxy())
ad._ip_requests = 2
assert ad._budget_reached() is False
def test_default_budget_is_conservative():
ad = _adapter(proxy=_proxy())
assert ad._ip_budget == 3 # 실측상 5회 부근 차단 → 기본 3회
def test_rotate_ip_records_end_reason_and_recycles():
ad = _adapter(proxy=_proxy())
before = ad._proxy.current_port
ad._rotate_ip("예산 도달", kind="budget", warn=False)
assert ad._end_reason == "budget"
assert ad._force_recycle is True
assert ad._proxy.current_port != before # 즉시 다음 포트
# ---- 세션 종료 기록 -------------------------------------------------------
async def test_session_end_event_shape():
events = []
async def cb(e):
events.append(e)
ad = _adapter(proxy=_proxy(), on_session_end=cb)
ad._ip_requests, ad._sess_ok, ad._sess_blocked = 3, 2, 1
ad._current_port, ad._end_reason = 10005, "budget"
await ad._record_session_end()
assert events == [{
"source": "coupang", "proxy_port": 10005, "requests": 3,
"ok_count": 2, "blocked_count": 1, "elapsed_sec": events[0]["elapsed_sec"],
"end_reason": "budget",
}]
assert ad._end_reason is None # 기록 후 리셋
async def test_session_end_skips_empty_session():
events = []
async def cb(e):
events.append(e)
ad = _adapter(on_session_end=cb)
ad._ip_requests = 0 # 요청 없던 세션(유휴 정리 등)은 노이즈 — 미기록
await ad._record_session_end()
assert events == []
async def test_session_end_defaults_to_window_reason():
events = []
async def cb(e):
events.append(e)
ad = _adapter(on_session_end=cb)
ad._ip_requests = 2 # end_reason 미지정 = 시간창 만료 재기동
await ad._record_session_end()
assert events[0]["end_reason"] == "window"
async def test_session_end_callback_failure_is_swallowed():
async def boom(e):
raise RuntimeError("db down")
ad = _adapter(on_session_end=boom)
ad._ip_requests = 1
await ad._record_session_end() # 예외가 검색을 막지 않는다
# ---- CRUD (실 lps_db) ----------------------------------------------------
@pytest_asyncio.fixture
async def ip_log(db_engine):
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE ip_session"))
return IpSessionLog()
async def test_record_persists_session(ip_log, db_engine):
await ip_log.record({"source": "coupang", "proxy_port": 10003, "requests": 3,
"ok_count": 3, "blocked_count": 0, "elapsed_sec": 120, "end_reason": "budget"})
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT source, proxy_port, requests, ok_count, end_reason FROM ip_session"
))).first()
assert row.source == "coupang" and row.proxy_port == 10003
assert row.requests == 3 and row.ok_count == 3 and row.end_reason == "budget"
async def test_recent_stats_groups_by_reason(ip_log, db_engine):
for reason in ("budget", "budget", "block"):
await ip_log.record({"source": "coupang", "requests": 3, "ok_count": 3,
"blocked_count": 0, "end_reason": reason})
stats = await ip_log.recent_stats(60)
assert stats == {"budget": 2, "block": 1}

View File

@ -91,3 +91,26 @@ def test_backoff_is_exponential_capped():
assert compute_backoff(2, base=5) == 10
assert compute_backoff(3, base=5) == 20
assert compute_backoff(100, base=5, cap=600) == 600
async def test_ops_counts_deadline_and_cost(q):
# 완료 잡 2건의 검색원가 합산 — claim 반환 잡을 완료(순서 의존 제거)
for code, cost in (("b", 0.01), ("c", 0.02)):
await q.enqueue(JobType.SEARCH.value, {"q": code})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1", {"metrics": {"cost": {"total_usd": cost}}})
# 데드라인 강제종료 — 재큐(PENDING)로 살아나도 deadline_1h 에 잡혀야 한다(dead 와 별개 축)
j1 = await q.enqueue(JobType.SEARCH.value, {"q": "a"})
await q.claim("w1")
await q.fail(j1, "w1", "JobDeadlineExceeded: 300s", backoff_sec=0)
snap = await q.ops()
assert snap["deadline_1h"] == 1
assert abs(snap["cost_1h_usd"] - 0.03) < 1e-9
async def test_ops_cost_ignores_jobs_without_metrics(q):
jid = await q.enqueue(JobType.SEARCH.value, {"q": "x"})
await q.claim("w1")
await q.complete(jid, "w1", {"count": 3}) # metrics 없는 결과 — 합산에서 무시(0)
snap = await q.ops()
assert snap["cost_1h_usd"] == 0

View File

@ -89,6 +89,8 @@ async def test_ops_snapshot(client, clean_jobs):
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"):
for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec",
"blocks_1h", "deadline_1h", "pool_pct"):
assert k in j and isinstance(j[k], int)
assert isinstance(j["cost_1h_usd"], (int, float))
assert j["pending"] == 1

View File

@ -50,3 +50,38 @@ def test_rotate_advances_port_immediately():
assert 10001 <= after <= 10003
p.rotate(); p.rotate() # 3번 회전하면 한 바퀴 → 원위치
assert p._port() == before
def test_burned_port_is_skipped():
p = _p(port_start=10001, port_end=10003)
burned = p._port()
p.mark_burned(burned)
assert p._port() != burned # 쿨다운 중인 포트는 건너뜀
assert p.available_ports() == 2
def test_burned_port_returns_after_cooldown():
p = _p(port_start=10001, port_end=10003)
burned = p._port()
p.mark_burned(burned, cooldown_sec=0) # 즉시 만료
assert p._port() == burned # 만료 후엔 다시 사용 가능
assert p.available_ports() == 3
def test_all_burned_falls_back_to_earliest_expiry():
p = _p(port_start=10001, port_end=10002) # 포트 2개
first, second = 10001, 10002
p.mark_burned(first, cooldown_sec=10) # 먼저 만료
p.mark_burned(second, cooldown_sec=999)
assert p._port() == first # 전 포트 쿨다운 — 만료 임박 포트 사용(가용성 우선)
def test_mark_burned_ignores_none_port():
p = _p()
p.mark_burned(None) # 프록시 미사용 세션 — no-op
assert p.available_ports() == 10
def test_cooldown_default_at_least_30min():
assert _p(session_minutes=10).cooldown_sec == 1800 # max(sticky 600, 1800)
assert _p(session_minutes=60).cooldown_sec == 3600 # sticky 가 더 길면 sticky 만큼

View File

@ -10,13 +10,14 @@ import os
import signal
import time
import httpx
from common.alerts import AlertManager
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from config.server_configs import web_server_config, openai_config, decodo_config
from config.server_configs import web_server_config, openai_config, decodo_config, worker_config, alert_config
from crud.job_crud import JobQueue
from crud.negative_cache import NegativeCache
from crud.bot_detection import BotDetectionLog
from crud.ip_session import IpSessionLog
from crud.price_history import PriceHistory
from services.search.proxy import DecodoProxy
from services.search.coupang.adapter import CoupangAdapter
@ -34,16 +35,16 @@ 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.
# 재가동: [WorkerConfig].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()]
names = [s.strip() for s in worker_config.fallbacks 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)})")
LOG.w(f"[WorkerConfig].fallbacks 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})")
return [n for n in names if n in _FALLBACK_SOURCES]
@ -56,15 +57,16 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
n = proxy.port_end - proxy.port_start + 1
proxy.seed_offset(i * max(1, n // concurrency))
bot_log = BotDetectionLog()
ip_log = IpSessionLog()
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}"
# [WorkerConfig].profile_dir 를 영속 볼륨으로 두면 재시작해도 cf_clearance 등 쿠키 유지(재웜업 회피).
return f"{worker_config.profile_dir}/lps_{source}{suffix}"
adapters = {
"coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, on_detect=bot_log.record),
"coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy,
on_detect=bot_log.record, on_session_end=ip_log.record),
"naver": NaverAdapter(), # httpx 직접(프록시 미경유) — 워커별 인스턴스(last_bytes 경합 회피)
}
# 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라
@ -72,9 +74,11 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
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)
fallback_adapters[name] = ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy,
on_detect=bot_log.record, on_session_end=ip_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)
fallback_adapters[name] = EsmAdapter(name, headless=False, user_data_dir=_pf(name), proxy=proxy,
on_detect=bot_log.record, on_session_end=ip_log.record, max_block_retries=0)
# AI 도 워커별 인스턴스 — 공유 상태(last_usage) 경합 원천 제거
judge = SimilarityJudge() if has_openai else None
keyword_gen = KeywordGenerator() if has_openai else None
@ -109,23 +113,29 @@ async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float
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
def _proxy_ports_snapshot(adapters) -> tuple[int, int] | None:
"""워커 프록시들의 가용 포트 현황 — (최소 가용 수, 전체 포트 수). 프록시 미사용이면 None.
쿨다운 맵은 프록시 인스턴스(워커)별이라 가장 소진된 워커 기준(min)으로 본다."""
proxies = {}
for ad in (adapters or []):
p = getattr(ad, "_proxy", None)
if p is not None and p.enabled:
proxies[id(p)] = p
if not proxies:
return None
any_p = next(iter(proxies.values()))
total = any_p.port_end - any_p.port_start + 1
return min(p.available_ports() for p in proxies.values()), total
async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0):
async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters=None, alerts=None, ip_log=None):
"""워커 헬스 하트비트 + 임계 알림. 주기적으로 (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"))
행/좀비 워커 감지) (2) 큐/차단/DB풀/소스별 실패 지표 점검 → AlertManager 로 발화
(룰별 쿨다운으로 스팸 방지, 조건 해소 시 회복 알림)."""
hb_path = worker_config.heartbeat_file
th = alert_config # 임계값은 [AlertConfig] 섹션이 소스(docs/operations.md 표)
alerts = alerts or AlertManager(origin="worker")
ip_log = ip_log or IpSessionLog()
while not stop.is_set():
try:
with open(hb_path, "w") as f:
@ -135,16 +145,40 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0):
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)
pool = DB_SESSION_MNG.pool_status()
snap["pool_pct"] = pool["pct"]
await alerts.check("dead", snap["dead_1h"] >= th.dead_1h, f"DEAD 1h={snap['dead_1h']}", snap)
await alerts.check("blocks", snap["blocks_1h"] >= th.blocks_1h, f"차단 1h={snap['blocks_1h']}", snap)
await alerts.check("queue_lag", snap["oldest_pending_sec"] >= th.queue_lag_sec, f"큐지연={snap['oldest_pending_sec']}s", snap)
await alerts.check("stuck", snap["stuck_running"] > 0, f"stuck={snap['stuck_running']}", snap)
await alerts.check("db_pool", pool["pct"] >= th.pool_pct,
f"DB 풀 포화 {pool['pct']}% (checked_out {pool['checked_out']}/{pool['capacity']})", snap)
await alerts.check("deadline", snap["deadline_1h"] >= th.deadline_1h,
f"잡 데드라인 강제종료 1h={snap['deadline_1h']} — 크롤 행 반복 신호", snap)
await alerts.check("cost", snap["cost_1h_usd"] >= th.cost_1h_usd,
f"검색원가 1h=${snap['cost_1h_usd']} — 비용 폭주(리소스차단 풀림·재시도 루프) 점검", snap)
# 가용 프록시 포트 고갈 — 쿨다운 격리 누적. blocks_1h 보다 먼저 우는 대규모 차단 조기 신호.
ports = _proxy_ports_snapshot(adapters)
if ports:
avail, total = ports
snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total
await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct,
f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap)
# 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호.
block_sessions = (await ip_log.recent_stats(360)).get("block", 0)
snap["block_sessions_6h"] = block_sessions
await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h,
f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap)
# 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호)
per_source: dict[str, list[int]] = {}
for ad in (adapters or []):
tries, ok = ad.recent_stats(1800)
agg = per_source.setdefault(ad.source, [0, 0])
agg[0] += tries
agg[1] += ok
for src, (tries, ok) in per_source.items():
await alerts.check(f"source_fail:{src}", tries >= th.source_fail_30m and ok == 0,
f"{src} 최근 30분 {tries}회 시도·성공 0건", snap)
except Exception as ex:
LOG.e_no_callstack(f"[ops-monitor] {type(ex).__name__}: {ex}")
try:
@ -218,7 +252,7 @@ async def main(concurrency: int = 1):
loop.add_signal_handler(sig, _request_stop, sig.name)
# 잡 1건 데드라인 — 정상 검색은 폴백 포함 수분 내 끝난다(실측 15~22s). 크롤 행 실측(15분) 대비 상한.
job_deadline = float(os.environ.get("LPS_JOB_DEADLINE_SEC", "300"))
job_deadline = worker_config.job_deadline_sec
for i in range(concurrency):
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history)
all_adapters += worker_adapters
@ -231,12 +265,12 @@ async def main(concurrency: int = 1):
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))) # 하트비트 + 임계 알림
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop, adapters=all_adapters))) # 하트비트 + 임계 알림
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"))
grace = worker_config.shutdown_grace_sec
gathered = asyncio.gather(*tasks)
stop_waiter = asyncio.create_task(stop.wait())
try:
@ -273,4 +307,7 @@ async def main(concurrency: int = 1):
if __name__ == "__main__":
asyncio.run(main(int(os.environ.get("WORKER_CONCURRENCY", "1"))))
# 동시성은 [WorkerConfig].concurrency 가 소스 — WORKER_CONCURRENCY env 는 실행 스크립트의
# 대화형 입력 전용 임시 override(설정 관리는 toml 하나로, 2026-07-13 협의).
_conc = int(os.environ.get("WORKER_CONCURRENCY", "0")) or worker_config.concurrency
asyncio.run(main(_conc))

View File

@ -132,6 +132,8 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
fail_reason = Column(String(100), nullable=True) # 실패 사유(예: not_found)
ai_model = Column(SmallInteger, nullable=True) # (예약) AI 모델 코드 — LPS 계약엔 미포함
crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함
lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용
lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용
crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준)

View File

@ -11,6 +11,7 @@ class WebServerConfig(ConfigModel):
nego_chat_url: str = "http://localhost:3300"
agent_base_url: str = "http://localhost:9500" # 협상 agent(9500). 공용 카탈로그 변경 알림용.
lps_base_url: str = "http://localhost:9600" # 인터넷 최저가 검색 LPS(9600). 검색요청 enqueue 용.
lps_api_key: str = "" # LPS API guard 키 — LPS 쪽 [WebServerConfig].api_keys 와 동일 값. 빈값=헤더 미첨부(개발)
class LogConfig(ConfigModel):

View File

@ -63,3 +63,6 @@ _apply_lps_db_env_override(lps_db_config)
# LPS API 주소 env override (도커: http://lps-api:9600 또는 host.docker.internal:9600)
if os.environ.get("LPS_BASE_URL"):
web_server_config.lps_base_url = os.environ["LPS_BASE_URL"]
# LPS API guard 키 (LPS 는 toml 단일 관리로 전환 — negodata 쪽은 기존 관례대로 toml+env override 유지)
if os.environ.get("LPS_API_KEY"):
web_server_config.lps_api_key = os.environ["LPS_API_KEY"]

View File

@ -25,6 +25,10 @@ _price_history = table(
column("outcome"), # found | not_found
column("final_lowest"), # 전체 최저가(원)
column("final_source"), # naver | coupang | (폴백몰)
column("naver_name"), # 네이버 최저가 상품명/링크 — final_source 에 맞는 출처를 실어온다
column("naver_url"),
column("coupang_name"), # 쿠팡 최저가 상품명/링크
column("coupang_url"),
column("created_at"),
)
@ -108,6 +112,10 @@ class LpsSyncCRUD(ILpsSyncCRUD):
_price_history.c.outcome,
_price_history.c.final_lowest,
_price_history.c.final_source,
_price_history.c.naver_name,
_price_history.c.naver_url,
_price_history.c.coupang_name,
_price_history.c.coupang_url,
_price_history.c.created_at,
).order_by(_price_history.c.created_at.asc())
if since is not None:

View File

@ -143,6 +143,8 @@ class LowestPriceEntry(WebPacketProtocol):
website: int = 0 # LowestPriceWebsite 코드(1=naver 2=coupang …)
success_yn: bool = False
fail_reason: Optional[str] = None
lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용
lp_url: Optional[str] = None # 찾은 판매 페이지 링크
crawl_end_time: Optional[datetime] = None

View File

@ -72,9 +72,11 @@ class LpsSyncService:
"price": str(item.price) if item.price else "",
}
base = web_server_config.lps_base_url.rstrip("/")
# LPS API guard: prod 는 lps_api_key 를 채워 X-API-Key 로 인증(개발은 빈값=개방 모드).
headers = {"X-API-Key": web_server_config.lps_api_key} if web_server_config.lps_api_key else None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]})
r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]}, headers=headers)
r.raise_for_status()
body = r.json()
entry = (body.get("items") or [{}])[0]
@ -129,11 +131,18 @@ class LpsSyncService:
# product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵
parsed = []
for code, outcome, final_lowest, final_source, created_at in rows:
for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, created_at in rows:
try:
parsed.append((uuid.UUID(code), outcome, final_lowest, final_source, created_at))
iid = uuid.UUID(code)
except (ValueError, AttributeError, TypeError):
results["skipped_not_uuid"] += 1
continue
# 출처(찾은 상품명·링크) — 최종 최저가를 낸 소스의 것을 싣는다(근거 검증용)
src_name, src_url = {
"naver": (nv_name, nv_url),
"coupang": (cp_name, cp_url),
}.get((final_source or "").lower(), (None, None))
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, created_at))
err, existing = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value,
@ -143,7 +152,7 @@ class LpsSyncService:
return results
history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price)
for item_id, outcome, final_lowest, final_source, created_at in parsed:
for item_id, outcome, final_lowest, final_source, src_name, src_url, created_at in parsed:
if item_id not in existing:
results["skipped_unknown_item"] += 1
continue
@ -154,6 +163,8 @@ class LpsSyncService:
website=LowestPriceWebsite.from_source(final_source).value,
success_yn=found,
fail_reason=None if found else (outcome or "unknown")[:100],
lp_name=(src_name or None) and src_name[:300],
lp_url=src_url or None,
crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존
))
results["found" if found else "not_found"] += 1

View File

@ -86,7 +86,9 @@ export * from './listUsersParams';
export * from './lowestPriceEntry';
export * from './lowestPriceEntryCrawlEndTime';
export * from './lowestPriceEntryFailReason';
export * from './lowestPriceEntryLpName';
export * from './lowestPriceEntryLpPrice';
export * from './lowestPriceEntryLpUrl';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';

View File

@ -6,6 +6,8 @@
*/
import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName';
import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl';
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
/**
@ -16,5 +18,7 @@ export interface LowestPriceEntry {
website?: number;
success_yn?: boolean;
fail_reason?: LowestPriceEntryFailReason;
lp_name?: LowestPriceEntryLpName;
lp_url?: LowestPriceEntryLpUrl;
crawl_end_time?: LowestPriceEntryCrawlEndTime;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntryLpName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntryLpUrl = string | null;

View File

@ -0,0 +1,232 @@
import type { ReactNode } from 'react';
import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react';
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Sheet } from '@/components/ui/sheet';
import { Typography } from '@/components/ui/typography';
import { useGetLowestPrice } from '@/api/generated/item/item';
import type { LowestPriceEntry } from '@/api/generated/model';
import type { Product } from '../types';
type LowestPriceHistorySheetProps = {
product: Product;
onClose: () => void;
};
// LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨
const WEBSITE_LABEL: Record<number, string> = {
1: '네이버',
2: '쿠팡',
3: 'G마켓',
4: '옥션',
5: '11번가',
99: '기타',
};
const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타';
// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법).
const chartConfig = {
price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } },
} satisfies ChartConfig;
const won = (n: number) => `₩${Math.round(n).toLocaleString()}`;
const timeLabel = (iso: string) =>
new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */
function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
return (
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
<div className="flex items-center justify-between border-b border-border pb-1">
<span className="font-bold text-foreground text-[11px] font-sans">{title}</span>
{action}
</div>
{children}
</Card>
);
}
// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력.
// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다.
export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) {
// GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다.
const { data, isLoading } = useGetLowestPrice(product.item_id);
const entries = data?.results ?? [];
const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null);
const latest = successes[0]; // API 가 최신순으로 준다
const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null;
const diff = product.price != null && representative != null ? Number(representative) - product.price : null;
// 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다.
const chartData = [...successes]
.reverse()
.map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price }));
return (
<Sheet open title="인터넷 최저가 상세" onClose={onClose}>
<div className="space-y-4">
{/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */}
<div className="rounded border border-border/80 bg-muted/25 p-3">
<Typography as="p" variant="caption" className="font-medium">{product.name}</Typography>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="font-mono text-[26px] leading-none font-bold text-rose-600 dark:text-rose-400">
{representative != null ? won(Number(representative)) : '수집 전'}
</span>
{diff != null && (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
diff <= 0
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
}`}
>
{diff <= 0 ? <TrendingDown size={12} /> : <TrendingUp size={12} />}
단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))}
</span>
)}
</div>
<Typography as="p" variant="caption" className="mt-1.5">
상품 단가 {product.price != null ? won(product.price) : '-'}
{latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}</>}
</Typography>
</div>
{/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */}
{latest && (
<SectionCard
title="최저가 출처"
action={<Badge variant="secondary" className="text-[10px]">{websiteLabel(latest.website)}</Badge>}
>
{latest.lp_name ? (
<Typography as="p" variant="small" className="text-[12.5px] leading-snug">{latest.lp_name}</Typography>
) : (
<Typography as="p" variant="caption">출처 상세 미수집(이전 버전 수집분)</Typography>
)}
{latest.lp_url && (
<a
href={latest.lp_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-fit items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-[11px] font-medium text-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
>
판매 페이지에서 확인 <ExternalLink size={11} />
</a>
)}
</SectionCard>
)}
{/* ── 가격 추이 ── */}
<SectionCard
title="가격 추이"
action={
successes.length > 0 ? (
<Typography variant="caption">{successes.length}회 수집</Typography>
) : undefined
}
>
{chartData.length >= 2 ? (
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<LineChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="t" tickLine={false} axisLine={false} tickMargin={8} tickFormatter={timeLabel} fontSize={10} />
<YAxis
tickLine={false}
axisLine={false}
width={52}
domain={['dataMin', 'dataMax']}
tickFormatter={(v) => Number(v).toLocaleString()}
fontSize={10}
/>
<ChartTooltip cursor={{ strokeDasharray: '3 3' }} content={<PriceTooltip />} />
<Line
dataKey="price"
type="monotone"
stroke="var(--color-price)"
strokeWidth={2}
dot={{ r: 3, fill: 'var(--color-price)', strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ChartContainer>
) : (
<div className="flex items-center gap-2 py-3 text-muted-foreground">
<ChartSpline size={14} className="shrink-0" />
<Typography variant="caption">
{successes.length === 1
? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.'
: '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'}
</Typography>
</div>
)}
</SectionCard>
{/* ── 수집 이력 ── */}
<SectionCard title="최근 수집 이력">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
<Typography variant="caption">이력을 불러오는 중...</Typography>
</div>
) : entries.length === 0 ? (
<div className="space-y-1 rounded border border-dashed border-border bg-muted/10 py-6 text-center">
<TrendingDown size={18} className="mx-auto text-muted-foreground/60" />
<Typography as="p" variant="caption">
수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요.
</Typography>
</div>
) : (
<ul className="divide-y divide-border/60">
{entries.map((e, i) => (
<li key={i} className="flex items-center justify-between gap-2 py-2 first:pt-0.5 last:pb-0.5">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="w-14 shrink-0 justify-center text-[10px]">
{websiteLabel(e.website)}
</Badge>
<span className="truncate text-[11px] text-muted-foreground">
{e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'}
</span>
</div>
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[12px]">
{e.success_yn && e.lp_price != null ? (
<span className="font-semibold text-rose-600 dark:text-rose-400">{won(e.lp_price)}</span>
) : (
<span className="text-amber-600 dark:text-amber-400">미발견</span>
)}
{e.lp_url ? (
<a
href={e.lp_url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground transition-colors hover:text-rose-600 dark:hover:text-rose-400"
title="판매 페이지 열기"
>
<ExternalLink size={12} />
</a>
) : (
<span className="w-3" /> /* 링크 없는 행도 가격 우측 정렬 유지 */
)}
</div>
</li>
))}
</ul>
)}
</SectionCard>
</div>
</Sheet>
);
}
// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만)
function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<Typography as="p" variant="caption" className="mb-0.5 font-medium text-foreground">{timeLabel(p.t)}</Typography>
<Typography as="p" variant="caption" className="font-mono text-foreground">{won(p.price)}</Typography>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Image as ImageIcon } from 'lucide-react';
import { ChartSpline, Image as ImageIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { DataTable, type Column } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination';
@ -10,6 +10,7 @@ type ProductTableProps = {
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
onRowClick: (prod: Product) => void;
onLowestPriceClick: (prod: Product) => void; // 인터넷 최저가 셀 클릭 → 출처·이력 시트
page: number;
totalPages: number;
totalCount: number;
@ -24,6 +25,7 @@ export function ProductTable({
selectedIds,
onSelectionChange,
onRowClick,
onLowestPriceClick,
page,
totalPages,
totalCount,
@ -114,7 +116,24 @@ export function ProductTable({
header: label('item.internet_lowest_price'),
align: 'right',
cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400',
cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'),
// 값 + 상세보기 버튼(항상 노출) → 출처(사이트·링크)·가격 추이 시트. 행 클릭과 전파 차단.
cell: (prod) => (
<div className="flex items-center justify-end gap-1.5">
<span>{prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLowestPriceClick(prod);
}}
className="cursor-pointer shrink-0 rounded border border-border/70 p-1 text-muted-foreground transition-colors hover:border-rose-400/60 hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400"
title="출처·가격 추이 상세보기"
aria-label="인터넷 최저가 상세보기"
>
<ChartSpline size={13} />
</button>
</div>
),
},
{
header: label('creator'),

View File

@ -16,6 +16,7 @@ import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
import { ProductTable } from '@/features/products/components/ProductTable';
import { ProductFormSheet } from '@/features/products/components/ProductFormSheet';
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet';
import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/components/ExcelUploadModal';
import { type Product } from '@/features/products/types';
@ -51,13 +52,16 @@ export default function ProductsPage() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 딥링크·뒤로가기·새로고침 지원
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
const overlay = useOverlayRouter(['new', 'detail', 'modal', 'lowest']);
const editId = overlay.get('detail');
const modal = overlay.get('modal'); // 'price' | 'excel' | null
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
const isFormOpen = overlay.has('new') || !!editing;
const lowestId = overlay.get('lowest');
const lowestProduct = products.find((prod) => prod.item_id === lowestId) ?? null;
const openCreate = () => overlay.open('new');
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
@ -175,6 +179,7 @@ export default function ProductsPage() {
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onRowClick={openEdit}
onLowestPriceClick={(prod) => overlay.open('lowest', prod.item_id)}
page={list.page}
totalPages={totalPages}
totalCount={total}
@ -209,6 +214,10 @@ export default function ProductsPage() {
/>
)}
{lowestProduct && (
<LowestPriceHistorySheet product={lowestProduct} onClose={overlay.close} />
)}
{modal === 'excel' && (
<ExcelUploadModal
open

View File

@ -0,0 +1,6 @@
-- 2026-07-10 · 최저가 수집 이력에 출처(찾은 상품명·링크) 추가
-- 인터넷 최저가는 견적 목표가·협상 카드(시장가 근거)의 근거값이므로, MD 가 클릭 한 번으로
-- 검증할 수 있어야 한다. LPS(price_history)의 naver/coupang name·url 을 동기화 때 함께 실어온다.
-- 기존 행은 NULL(출처 미수집 — 이 컬럼 추가 이전 수집분)로 자연 공존.
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_name VARCHAR(300) NULL; -- 찾은 상품명(판매 페이지 기준)
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS lp_url TEXT NULL; -- 찾은 판매 페이지 링크

View File

@ -183,7 +183,9 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
success_yn BOOLEAN NOT NULL, -- 크롤링 성공 여부
fail_reason VARCHAR(100) NULL, -- 실패 사유
ai_model SMALLINT NULL, -- 사용한 AI 모델 (코드, 앱 enum 매핑)
crawl_duration_ms INTEGER NULL, -- 크롤링 소요 시간(ms)
crawl_duration_ms INTEGER NULL,
lp_name VARCHAR(300) NULL, -- [2026-07-10] 찾은 상품명(판매 페이지 기준)
lp_url TEXT NULL, -- [2026-07-10] 찾은 판매 페이지 링크(근거 검증용) -- 크롤링 소요 시간(ms)
crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
@ -754,6 +756,20 @@ CREATE TABLE IF NOT EXISTS price_history (
);
CREATE INDEX IF NOT EXISTS ix_price_history_product ON price_history (product_code, triggered_at);
-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터
CREATE TABLE IF NOT EXISTS ip_session (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source VARCHAR(20) NOT NULL, -- coupang 등
proxy_port INTEGER NULL, -- 사용 포트(=IP 세션), 프록시 미사용이면 NULL
requests INTEGER NOT NULL, -- 이 IP 로 보낸 요청 수
ok_count INTEGER NOT NULL DEFAULT 0, -- 성공 검색 수
blocked_count INTEGER NOT NULL DEFAULT 0, -- 차단 감지 수
elapsed_sec INTEGER NULL, -- 세션 지속 시간(초)
end_reason VARCHAR(20) NOT NULL, -- budget/block/proxy_error/window/idle/shutdown/rotate
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 세션 종료 시각
);
CREATE INDEX IF NOT EXISTS ix_ip_session_source ON ip_session (source, created_at);
-- 봇 감지 이력 — IP·요청순번·차단방식 축적(패턴 분석)
CREATE TABLE IF NOT EXISTS bot_detection (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),