diff --git a/.env.example b/.env.example deleted file mode 100644 index 487ecfd..0000000 --- a/.env.example +++ /dev/null @@ -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) diff --git a/.gitignore b/.gitignore index c09a7da..3fb70c5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ CLAUDE.md /new.md /mobile.mov +.gstack/ diff --git a/docker-compose.yml b/docker-compose.yml index d429772..a7dde9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 -p -U -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 diff --git a/lps-admin/.dockerignore b/lps-admin/.dockerignore new file mode 100644 index 0000000..05907cc --- /dev/null +++ b/lps-admin/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +*.local +tsconfig.tsbuildinfo diff --git a/lps-admin/.gitignore b/lps-admin/.gitignore new file mode 100644 index 0000000..804bd4a --- /dev/null +++ b/lps-admin/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.local diff --git a/lps-admin/Dockerfile b/lps-admin/Dockerfile new file mode 100644 index 0000000..815bf4f --- /dev/null +++ b/lps-admin/Dockerfile @@ -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 diff --git a/lps-admin/README.md b/lps-admin/README.md new file mode 100644 index 0000000..60afc72 --- /dev/null +++ b/lps-admin/README.md @@ -0,0 +1,38 @@ +# LPS 관리자 페이지 + +LPS(인터넷 최저가 검색)의 운영 지표를 보고 필수 액션(수동 재검색·DEAD 재큐)을 수행하는 +React 관리자 페이지입니다. 서버 동작 설정은 여기서 바꾸지 않습니다 — 설정 소스는 서버의 +`config..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://: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/` diff --git a/lps-admin/index.html b/lps-admin/index.html new file mode 100644 index 0000000..2bbc8a0 --- /dev/null +++ b/lps-admin/index.html @@ -0,0 +1,12 @@ + + + + + + LPS 관리자 — 인터넷 최저가 검색 + + +
+ + + diff --git a/lps-admin/nginx.conf b/lps-admin/nginx.conf new file mode 100644 index 0000000..df29633 --- /dev/null +++ b/lps-admin/nginx.conf @@ -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"; + } +} diff --git a/lps-admin/package-lock.json b/lps-admin/package-lock.json new file mode 100644 index 0000000..c22f8a6 --- /dev/null +++ b/lps-admin/package-lock.json @@ -0,0 +1,2795 @@ +{ + "name": "lps-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lps-admin", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/lps-admin/package.json b/lps-admin/package.json new file mode 100644 index 0000000..48c72fe --- /dev/null +++ b/lps-admin/package.json @@ -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" + } +} diff --git a/lps-admin/src/api/client.ts b/lps-admin/src/api/client.ts new file mode 100644 index 0000000..e4b4a06 --- /dev/null +++ b/lps-admin/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + const headers: Record = { ...((init?.headers as Record) ?? {}) }; + 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 = (path: string) => request(path); + +export const post = (path: string, payload?: unknown) => + request(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + +/** healthz 는 JSON 문자열("시각")을 반환 — 도달 여부만 본다. */ +export async function healthz(): Promise { + try { + const res = await fetch(settings.baseUrl() + "/healthz"); + return res.ok; + } catch { + return false; + } +} diff --git a/lps-admin/src/api/types.ts b/lps-admin/src/api/types.ts new file mode 100644 index 0000000..df55156 --- /dev/null +++ b/lps-admin/src/api/types.ts @@ -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; +} + +/** 잡 결과(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; + 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; + 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[]; +} diff --git a/lps-admin/src/components/Layout.tsx b/lps-admin/src/components/Layout.tsx new file mode 100644 index 0000000..a81e469 --- /dev/null +++ b/lps-admin/src/components/Layout.tsx @@ -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 ( +
+ {/* 모바일( +
+
+ L +
+
LPS 관리자
+
인터넷 최저가 검색
+
+
+ {/* API 헬스 — 상단 배지(모바일: 우측, 데스크톱: 제목 아래) */} +
+ + + {up ? "API 연결됨" : "API 연결 안 됨"} + +
+
+ + + {/* content 영역만 스크롤 — 페이지가 h-full 로 채우면 스크롤 없음, 넘치면 여기서 스크롤. */} +
+ +
+
+ ); +} diff --git a/lps-admin/src/components/ui.tsx b/lps-admin/src/components/ui.tsx new file mode 100644 index 0000000..5f2825d --- /dev/null +++ b/lps-admin/src/components/ui.tsx @@ -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 ( +
+ {title !== undefined && ( +
+

{title}

+ {hint &&
{hint}
} +
+ )} +
{children}
+
+ ); +} + +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 ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +const STATUS_STYLE: Record = { + 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 ( + + + {s.label} + + ); +} + +export function Empty({ children }: { children: ReactNode }) { + return

{children}

; +} + +export function ErrorNote({ error }: { error: unknown }) { + return ( +

+ {error instanceof Error ? error.message : "요청에 실패했습니다"} +

+ ); +} + +export function Loading() { + return

불러오는 중…

; +} + +// ── 페이지 헤더 — h1 + 우측 컨트롤 슬롯(기간 탭 등). 모든 페이지 제목을 이걸로 통일. ── +export function PageHeader({ title, children }: { title: ReactNode; children?: ReactNode }) { + return ( +
+

{title}

+ {children &&
{children}
} +
+ ); +} + +// ── 세그먼트 탭 — 상태/기간/상위N 필터 공용(기존 4곳의 동일 마크업 통일). ── +export function Segmented({ 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 ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ── 버튼 — primary(실행 CTA)·outline(보조). 크기 sm/md/lg. 반복되던 파랑 버튼 통일. ── +export function Button({ variant = "primary", size = "md", className = "", ...props }: { + variant?: "primary" | "outline"; size?: "sm" | "md" | "lg"; +} & ButtonHTMLAttributes) { + 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 + + ); +} + +// ── 범례 — 색 dot + 라벨(차트 아래, Dashboard·Costs·Products 통일). ── +export function Legend({ items, note }: { + items: readonly { label: ReactNode; color: string }[]; note?: ReactNode; +}) { + return ( +

+ {items.map((it, i) => ( + + + {it.label} + + ))} + {note && {note}} +

+ ); +} + +// ── 스크롤 컨테이너 — height 숫자면 고정 높이, "fill" 이면 부모의 남는 높이를 채움. ── +export function ScrollBox({ height = 420, className = "", children }: { + height?: number | "fill"; className?: string; children: ReactNode; +}) { + const fill = height === "fill"; + return ( +
{children}
+ ); +} + +// ── 모달 — 배경 딤 + 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 ( +
+
+
+
+

{title}

+
+ {actions} + +
+
+
{children}
+
+
+ ); +} + +// ── 표 — 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 ( +
+ + + + {columns.map((c) => ( + + ))} + + + {children} +
+ {c.label} +
+
+ ); +} diff --git a/lps-admin/src/index.css b/lps-admin/src/index.css new file mode 100644 index 0000000..2261085 --- /dev/null +++ b/lps-admin/src/index.css @@ -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; +} diff --git a/lps-admin/src/lib/chart.ts b/lps-admin/src/lib/chart.ts new file mode 100644 index 0000000..1d52cd5 --- /dev/null +++ b/lps-admin/src/lib/chart.ts @@ -0,0 +1,35 @@ +/** recharts 공용 축·그리드 props — 반복되던 XAxis/YAxis/Grid 설정 단일화. + * 색은 index.css @theme 토큰 참조(단일 소스). 사용: */ + +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 툴팁을 앱 톤으로 통일). +// 사용: (막대엔 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; diff --git a/lps-admin/src/lib/format.ts b/lps-admin/src/lib/format.ts new file mode 100644 index 0000000..d30c82c --- /dev/null +++ b/lps-admin/src/lib/format.ts @@ -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)}분`; +} diff --git a/lps-admin/src/main.tsx b/lps-admin/src/main.tsx new file mode 100644 index 0000000..8c8010e --- /dev/null +++ b/lps-admin/src/main.tsx @@ -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: , + children: [ + { path: "/", element: }, + { path: "/jobs", element: }, + { path: "/products", element: }, + { path: "/crawl", element: }, + { path: "/costs", element: }, + { path: "/test", element: }, + { path: "/settings", element: }, + ], + }, +]); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/lps-admin/src/pages/Costs.tsx b/lps-admin/src/pages/Costs.tsx new file mode 100644 index 0000000..22ecf7e --- /dev/null +++ b/lps-admin/src/pages/Costs.tsx @@ -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(`/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 ( +
+ + + + + {cost.isError && } + +
+ + + + 0 ? `${Math.round((totals.proxy / totals.total) * 100)}%` : "—"} sub="대역폭이 원가의 대부분" /> +
+ + + {cost.isPending && } + {cost.data && buckets.length === 0 && ( +
기간 내 완료된 검색이 없습니다 — 워커를 켜고 검색을 돌리면 채워집니다
+ )} + {buckets.length > 0 && ( + <> +
+ + + + + `$${v}`} /> + [usd(v), name]} + labelFormatter={(l, payload) => { + const jobs = (payload?.[0]?.payload as { jobs?: number } | undefined)?.jobs; + return `${l}${jobs != null ? ` · 검색 ${jobs}건` : ""}`; + }} /> + + + + +
+ + + )} +
+ + + {buckets.length === 0 ? ( +
데이터 없음
+ ) : ( +
+ + + + + `${(v / 1000).toFixed(0)}s`} /> + [`${(v / 1000).toFixed(1)}초`, "평균 소요"]} /> + + + +
+ )} +
+
+ ); +} diff --git a/lps-admin/src/pages/Crawl.tsx b/lps-admin/src/pages/Crawl.tsx new file mode 100644 index 0000000..6d1bbf5 --- /dev/null +++ b/lps-admin/src/pages/Crawl.tsx @@ -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 = { + 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(`/v1/lps/stats/ip-sessions?hours=${hours}`), + refetchInterval: 30_000, + }); + const bot = useQuery({ + queryKey: ["bot", hours], + queryFn: () => get(`/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 ( +
+ + + + + {ip.isError && } + + {ip.data && ( + blockCount === 0 ? ( +

+ ✓ 기간 내 차단된 IP 세션 0건 — 요청 예산(선제 회전)이 잘 작동하고 있습니다 +

+ ) : ( +

+ ⚠ 예산 안에서도 차단된 세션 {blockCount}건 — 최소 {ip.data.block_min_requests}회 요청에서 차단됐습니다. + 요청 예산을 그보다 낮게 유지하세요(현재 서버 설정은 toml [DecodoConfig].ip_request_budget) +

+ ) + )} + +
+ + {ip.isPending && } + {ip.data && reasons.length === 0 && 기간 내 세션 없음 — 워커가 검색을 시작하면 쌓입니다} + {reasons.length > 0 && ( +
+ {/* 고정 크기 차트 — ResponsiveContainer 불필요(dev 경고 발생원). 중앙에 총계 오버레이. */} +
+ + + {reasons.map((r) => )} + + {/* 툴팁 제거 — 오른쪽 범례가 사유별 건수를 모두 보여줘 중복이고, 중앙 총계와 겹침 방지 */} + +
+
+
{totalSessions}
+
세션
+
+
+
+
    + {reasons.map((r) => ( +
  • + + {r.label} + {r.value} +
  • + ))} +
+
+ )} + {reasons.length > 0 && ( +

+ 초록(예산 선제)·파랑(시간창)이 대부분이면 건강한 상태입니다. 빨강(차단)이 보이면 예산 하향 신호. +

+ )} +
+ + + {ip.isPending && } + {ip.data && ip.data.histogram.length === 0 &&
데이터 없음
} + {ip.data && ip.data.histogram.length > 0 && ( + <> +
+ + + + + + + + + + + + [`${v}건`, "세션 수"]} labelFormatter={(l) => `IP당 ${l}회 요청`} /> + + {ip.data.block_min_requests != null && ( + + )} + + +
+

+ X축 = 한 IP로 보낸 요청 수. 분포가 예산값에 몰려 있으면 정상, 붉은 기준선(차단 시작점)보다 예산이 낮아야 안전. +

+ + )} +
+
+ +
+ + {bot.isPending && } + {bot.isError && } + {bot.data && bot.data.hourly.length === 0 &&
기간 내 차단 없음 🎉
} + {bot.data && bot.data.hourly.length > 0 && ( +
+ + ({ ...h, x: dateShort(h.bucket) }))} margin={{ top: 6, right: 12, bottom: 0, left: -22 }}> + + + + + + + + + + [`${v}건`, "차단"]} /> + + + +
+ )} +
+ + + {bot.data && bot.data.items.length === 0 && 기간 내 차단 없음} + {bot.data && bot.data.items.length > 0 && ( + + {bot.data.items.map((b, i) => ( + + {hhmm(b.created_at)} + {b.query || "—"} + {b.ip_request_no ?? "—"} + {b.proxy_port ?? "—"} + {b.marker} + + ))} + + )} + +
+ + + {ip.data && ip.data.sessions.length === 0 && 세션 없음} + {ip.data && ip.data.sessions.length > 0 && ( + + {ip.data.sessions.map((s, i) => { + const r = REASONS[s.end_reason] ?? { label: s.end_reason, color: "var(--color-ink-400)" }; + return ( + + {dateShort(s.created_at)} + {s.source} + {s.proxy_port ?? "—"} + {s.requests} + {s.ok_count}/{s.blocked_count} + {durationSec(s.elapsed_sec)} + + + + {r.label} + + + + ); + })} + + )} + +
+ ); +} diff --git a/lps-admin/src/pages/Dashboard.tsx b/lps-admin/src/pages/Dashboard.tsx new file mode 100644 index 0000000..cf090fd --- /dev/null +++ b/lps-admin/src/pages/Dashboard.tsx @@ -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([]); + const ops = useQuery({ + queryKey: ["ops"], + queryFn: () => get("/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 ( +
+ + + {ops.isError && } + {alerts.map((a) => ( +

+ ⚠ {a.msg} +

+ ))} + {d && alerts.length === 0 && ( +

+ ✓ 모든 지표가 임계 안에 있습니다 +

+ )} + +
+ + + + 0 ? "dead" : "default"} sub={`최근 1h ${d?.dead_1h ?? "—"}건`} /> + + 0 ? "warn" : "default"} sub="쿠팡 봇 감지" /> + + +
+ + +
+ {trend.current.length < 2 ? ( +
+

수집 중… 잠시 후 그래프가 나타납니다

+
+ ) : ( + + + + + + [v, name === "pending" ? "대기" : "처리중"]} /> + + + + + )} +
+ +
+ +

+ 임계 배너는 서버 알림 기본값 기준입니다(서버 toml 에서 조정했으면 실제 알림과 다를 수 있음). + 전체 룰은 lps/docs/operations.md 참고. +

+
+ ); +} diff --git a/lps-admin/src/pages/Jobs.tsx b/lps-admin/src/pages/Jobs.tsx new file mode 100644 index 0000000..a52d63e --- /dev/null +++ b/lps-admin/src/pages/Jobs.tsx @@ -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(""); + const [q, setQ] = useState(""); + const [qInput, setQInput] = useState(""); + const [page, setPage] = useState(0); + const [modalJob, setModalJob] = useState(null); + const [modalView, setModalView] = useState("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(`/v1/lps/jobs?${params}`), + refetchInterval: 15_000, + }); + const pages = Math.max(1, Math.ceil((list.data?.total ?? 0) / PAGE)); + + return ( +
+ + +
+ { setStatus(v); setPage(0); }} /> + + { setQ(qInput.trim()); setPage(0); }} /> +
+ + + {list.isPending && } + {list.isError && } + {list.data && list.data.items.length === 0 && 조건에 맞는 작업이 없습니다} + {list.data && list.data.items.length > 0 && ( + + {list.data.items.map((j) => ( + + +
{j.product_name || "—"}
+
{j.product_code}
+ + + {j.outcome === "found" ? "찾음" : j.outcome === "not_found" ? "없음" : "—"} + {won(j.final_lowest)} + {usd(j.cost_usd)} + {dateShort(j.created_at)} + +
+ + +
+ + + ))} +
+ )} + {pages > 1 && ( + + )} +
+ + {modalJob && ( + setModalJob(null)} /> + )} +
+ ); +} + +// 행별 "상세"/"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(`/v1/lps/jobs/${job.job_id}`), + }); + const requeue = useMutation({ + mutationFn: () => post(`/v1/lps/jobs/${job.job_id}/requeue`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["jobs"] }), + }); + const [copied, setCopied] = useState(false); + + const out = detail.data?.output as Record | 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 ( + 상세 — {job.product_name || job.product_code}} + onClose={onClose} + actions={ + <> + + {view === "json" && ( + + )} + + } + > + {view === "detail" ? ( + <> +
+ {job.job_id} + {job.product_name || "—"}{job.product_code && ({job.product_code})} + 시도 {job.attempts}/{job.max_attempts} + {job.last_error && {job.last_error}} + {detail.isPending && 불러오는 중…} + {out?.outcome && {out.outcome === "found" ? "같은 상품을 찾음" : "같은 상품 없음"}} + {out?.lowest && {won(out.lowest.price)} ({out.lowest.source} · {out.lowest.mall_name || "—"})} + {out?.metrics?.cost && ( + + {usd(out.metrics.cost.total_usd)} (AI {usd(out.metrics.cost.ai_usd)} + 프록시 {usd(out.metrics.cost.proxy_usd)}) + + )} + {out?.metrics?.duration_ms != null && {(out.metrics.duration_ms / 1000).toFixed(1)}초} +
+ + {job.status === "DEAD" && ( +
+ + {requeue.isError &&

{(requeue.error as Error).message}

} + {requeue.isSuccess &&

대기열에 다시 넣었습니다 — 워커가 곧 처리합니다

} +
+ )} + + ) : detail.isPending ? ( + + ) : detail.isError ? ( + + ) : out ? ( +
{jsonText}
+ ) : ( + 이 작업에는 원본 결과가 없습니다 (완료 전이거나 실패) + )} +
+ ); +} + +function Row({ k, children }: { k: string; children: React.ReactNode }) { + return ( +
+
{k}
+
{children}
+
+ ); +} diff --git a/lps-admin/src/pages/Products.tsx b/lps-admin/src/pages/Products.tsx new file mode 100644 index 0000000..03cfc12 --- /dev/null +++ b/lps-admin/src/pages/Products.tsx @@ -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 ( +
+
{label}
+
    + {SERIES.map((s) => { + const p = byKey.get(s.key); + if (p?.value == null) return null; + return ( +
  • + + {s.label} + {won(p.value)} +
  • + ); + })} +
+
+ ); +} + +export default function Products() { + const [q, setQ] = useState(""); + const [qInput, setQInput] = useState(""); + const [selected, setSelected] = useState(null); + + const list = useQuery({ + queryKey: ["products", q], + queryFn: () => get(`/v1/lps/products?limit=100${q ? `&q=${encodeURIComponent(q)}` : ""}`), + }); + + return ( +
+ + + setQ(qInput.trim())} /> + + + {list.isPending && } + {list.isError && } + {list.data && list.data.items.length === 0 && 아직 검색된 상품이 없습니다} + {list.data && list.data.items.length > 0 && ( + +
    + {list.data.items.map((p) => ( +
  • + +
  • + ))} +
+
+ )} +
+ + +
+ ); +} + +function ProductDetail({ product }: { product: ProductItem | null }) { + const qc = useQueryClient(); + // 그래프에서 클릭한 시점(triggered_at). 없으면 최신 시점. 상품이 바뀌어 목록에 없으면 자동으로 최신으로 폴백. + const [selectedTs, setSelectedTs] = useState(null); + const history = useQuery({ + queryKey: ["history", product?.product_code], + queryFn: () => get(`/v1/lps/products/${encodeURIComponent(product!.product_code)}/history?limit=200`), + enabled: !!product, + }); + const research = useMutation({ + mutationFn: () => post("/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 ( + +
목록에서 상품을 선택하세요
+
+ ); + + 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: 자식이 콘텐츠(차트) 최소폭 때문에 안 줄어들어 가로 넘침 나는 것 방지. +
+ 가격 추이 — {product.display_name || product.product_code}} + hint={ + + }> + {research.isSuccess && ( +

+ {research.data.accepted > 0 ? "검색을 접수했습니다 — 잠시 후 새 점이 추가됩니다" : "이미 같은 상품 검색이 진행 중입니다"} +

+ )} + {research.isError &&
} +
+ {history.isPending ? ( +
+ ) : history.isError ? ( + + ) : points.length === 0 ? ( +
이력이 없습니다
+ ) : ( + + { + const ts = (state as { activePayload?: { payload?: PricePoint }[] })?.activePayload?.[0]?.payload?.triggered_at; + if (ts) setSelectedTs(ts); + }}> + + + + + + + + + v.toLocaleString("ko-KR")} domain={[0, "auto"]} /> + } cursor={{ stroke: "var(--color-line-200)", strokeWidth: 1 }} /> + {activePoint && ( + + )} + {SERIES.filter((s) => s.key !== "final").map((s) => ( + + ))} + + + + )} +
+ {points.length > 0 && ( + ({ label: s.label, color: s.color }))} + note="빈 구간 = 그 시점에 해당 소스에서 못 찾음" /> + )} +
+ + {activePoint ? ( + + ) : ( + +
최근 검색 데이터가 없습니다
+
+ )} +
+ ); +} + +// ── 몰별 가격 비교(최근 검색) — 컴팩트 표. 막대 제거, 소스(발견 채널) 뱃지 추가, 상품명은 보조줄로 유지. +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(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 ( + }> +

+ {dateShort(point.triggered_at)} 기준 · 그래프의 시점을 클릭해 이동 +

+ {shown.length === 0 ? ( +
몰별 데이터 없음
+ ) : ( + <> + + {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 ( + + + {i + 1} + + +
+ {srcLabel(m.source)} + {m.mall_name || m.source} + {i === 0 && 최저} +
+
{m.name}
+ + +
{won(price)}
+
+ {delta === 0 ? "최저" : `+${pct.toFixed(1)}%`} + {m.detail_url && ( + ↗ + )} +
+ + + ); + })} +
+ {topN !== "all" && malls.length > shown.length && ( +

몰 {malls.length}개 중 상위 {shown.length}개 — '전체'로 모두 보기

+ )} + + )} +
+ ); +} diff --git a/lps-admin/src/pages/Settings.tsx b/lps-admin/src/pages/Settings.tsx new file mode 100644 index 0000000..f2a5f7c --- /dev/null +++ b/lps-admin/src/pages/Settings.tsx @@ -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 ( +
+ + +
{ + e.preventDefault(); + settings.save(baseUrl.trim(), apiKey.trim()); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }} + > +
+ + 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" /> +

예: https://lps.example.com — dev 서버를 직접 보려면 해당 주소 입력

+
+
+ + 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" /> +

서버 toml 의 [WebServerConfig].api_keys 중 하나. 이 브라우저에만 저장됩니다.

+
+ + {saved && 저장했습니다} +
+
+

+ 지표 임계·요청 예산 등 서버 동작 설정은 이 화면에서 바꾸지 않습니다 — 설정 소스는 서버의 + config.<env>.toml 하나이며(2026-07-13 협의), 변경은 toml 수정 + 재시작으로 합니다. +

+
+ ); +} diff --git a/lps-admin/src/pages/TestSearch.tsx b/lps-admin/src/pages/TestSearch.tsx new file mode 100644 index 0000000..16a1ed9 --- /dev/null +++ b/lps-admin/src/pages/TestSearch.tsx @@ -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 = { 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 = { + 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({ product_name: "", model: "", specification: "", company: "", price: "" }); + const [jobId, setJobId] = useState(null); + const [testCode, setTestCode] = useState(null); + const startedAt = useRef(0); + + const submit = useMutation({ + mutationFn: async () => { + const code = makeTestCode(); + const res = await post("/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(`/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 ( +
+
+ +

+ 실제 크롤이라 프록시 비용 발생 · 비용 통계 집계 (상품코드 TEST-) +

+
+ +
+ {/* 왼쪽: 검색 조건 + 진행 상태(함께 sticky — 결과를 스크롤해도 폼·상태가 붙어 있음) */} +
+ +
{ e.preventDefault(); if (form.product_name.trim() && !jobId) submit.mutate(); }} + > + setForm((f) => ({ ...f, product_name: v }))} placeholder="예: 맥심 모카골드 커피믹스" /> +
+ setForm((f) => ({ ...f, model: v }))} placeholder="선택" /> + setForm((f) => ({ ...f, company: v }))} placeholder="선택" /> +
+ setForm((f) => ({ ...f, specification: v }))} placeholder="선택 — 예: 1박스 160개입" /> + setForm((f) => ({ ...f, price: v.replace(/[^0-9]/g, "") }))} placeholder="선택 — 있으면 가격밴드 필터 기준" /> +
+ {/* 앰버(warn) — 실제 크롤·프록시 비용이 드는 실행 액션. 제출 후엔 초기화 전까지 재검색 차단(중복 비용 방지). */} + + {jobId && ( + + )} +
+ {submit.isError && } + +
+ + {jobId && ( + + + {job.isError &&
} + {pendingTooLong && ( +

+ {elapsed}초째 대기 중 — 워커가 실행 중인지 확인하세요 (./run_local_worker.sh). 워커가 없으면 크롤이 시작되지 않습니다. +

+ )} + {status === "DEAD" && ( +

+ 검색 실패(재시도 소진) — {job.data?.last_error || "차단·오류로 결과를 얻지 못했습니다"} +

+ )} +
+ )} +
+ + {/* 오른쪽: 결과 */} +
+ {status === "DONE" && output ? ( + + ) : ( + + + {!jobId ? "왼쪽에서 상품명을 입력하고 검색을 실행하세요" + : status === "DEAD" ? "검색이 실패했습니다 — 왼쪽 진행 상태를 확인하세요" + : "검색 진행 중 — 완료되면 결과가 여기 표시됩니다"} + + + )} +
+
+
+ ); +} + +// ── 진행 스텝퍼 ────────────────────────────────────────────── +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 ( +
+ {steps.map((s, i) => { + const done = i < curIdx || status === "DONE"; + const active = i === curIdx && status !== "DONE"; + return ( +
+ + {active && } + {s.label} + + {i < steps.length - 1 && } +
+ ); + })} + {elapsed}초 +
+ ); +} + +// ── 결과 뷰: 판정 요약 + 퍼널 + 소스별 + 매칭 상품 + 비용 ────── +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 ( + <> + + {found ? ( +
+ 같은 상품 찾음 + {output.lowest && ( + + 최저가 {won(output.lowest.price)} + + ({sourceMall(output.lowest.source, output.lowest.mall_name)}) + + + )} +
+ ) : ( + + 같은 상품 없음 {output.rounds_tried ? `· ${output.rounds_tried}개 검색어로 시도` : ""} + + )} + {output.query &&

최종 검색어: {output.query}

} +
+ + {output.stages && output.stages.length > 0 && ( + + + + )} + +
+ {output.sources && Object.keys(output.sources).length > 0 && ( + + {output.query && ( +

+ 각 사이트 검색어 "{output.query}" +

+ )} +
    + {Object.entries(output.sources).map(([s, v]) => ( +
  • + {sourceLabel(s)} + + {v.error ? ( + 실패 · {v.error.split(":")[0]} + ) : ( + <>{v.count ?? 0}건 + )} + {m?.source_ms?.[s] != null && · {(m.source_ms[s] / 1000).toFixed(1)}초} + +
  • + ))} +
+
+ )} + + {m && ( + +
    +
  • 총 비용{usd(m.cost?.total_usd)}
  • +
  • AI 판정·검색어{usd(m.cost?.ai_usd)}
  • +
  • 프록시 대역폭{usd(m.cost?.proxy_usd)}
  • + {m.crawl && ( +
  • + 프록시 전송량 + {(m.crawl.proxy_bytes / 1024).toFixed(0)} KB · fetch {m.crawl.fetches}회 +
  • + )} +
+
+ )} +
+ + {output.top && output.top.length > 0 && ( + + + {output.top.map((p, i) => { + const src = srcColor(p.source); + const ship = shipInfo(p.shipping_fee, p.shipping_type); + return ( + + + {sourceLabel(p.source)} + + {p.name} + + {won(p.price)} + {i === 0 && 최저} + + + {ship.tone === "rocket" ? ( + 🚀 {ship.text} + ) : ( + {ship.text} + )} + + + {p.detail_url && ( + 사이트 → + )} + + + ); + })} + + + )} + +
+ +
+ + {showJson && ( + setShowJson(false)} + actions={}> +
{jsonText}
+
+ )} + + ); +} + +// ── 퍼널: 단계별 남은 건수 가로 막대(수집→최종 매칭) ────────── +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 ( +
    + {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)으로 짧게, 값·감소량은 우측 넓은 영역에 한 줄로. +
  • + {r.label} +
    +
    0 ? "3px" : 0 }} aria-hidden /> +
    + {r.value} + {/* 이전 단계 대비 감소량 — 어디서 확 걸러졌는지 한눈에 */} + + {drop == null ? "" : drop < 0 ? `−${-drop}${dropPct != null ? ` (${dropPct}%)` : ""}` : "—"} + +
  • + ); + })} +
+ ); +} + +// ── 폼 필드(라벨-인풋 연결, 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 ( +
+ + 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" /> +
+ ); +} diff --git a/lps-admin/tsconfig.json b/lps-admin/tsconfig.json new file mode 100644 index 0000000..ae1f21a --- /dev/null +++ b/lps-admin/tsconfig.json @@ -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"] +} diff --git a/lps-admin/tsconfig.tsbuildinfo b/lps-admin/tsconfig.tsbuildinfo new file mode 100644 index 0000000..6defad6 --- /dev/null +++ b/lps-admin/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/lps-admin/vite.config.ts b/lps-admin/vite.config.ts new file mode 100644 index 0000000..0e7e0ac --- /dev/null +++ b/lps-admin/vite.config.ts @@ -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 }, + }, + }, +}); diff --git a/lps-temp-fe/index.html b/lps-temp-fe/index.html deleted file mode 100644 index fb07459..0000000 --- a/lps-temp-fe/index.html +++ /dev/null @@ -1,672 +0,0 @@ - - - - - -LPS · 최저가 검색 콘솔 (임시 FE) - - - - -
-
-
- -
LPS 최저가 검색 콘솔 - 임시 프론트엔드 · API 통신 확인용 -
-
-
-
- PENDING – - RUNNING – - DONE – - DEAD – -
-
확인 중…
-
-
- -
- -
-
-
1

검색 요청

POST /v1/lps/search
-
-
- -
-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- -
-
-
- -
-

API 설정

Base URL
-
-
- - -
-

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

-
-
-
- - -
- -
-
2

작업 추적 & 상품 정보

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

최저가 이력 그래프

GET /v1/lps/products/{code}/history
-
-
-
- - -
- -
-
-
📈
상품 코드를 입력하고 이력을 불러오세요.
-
-
-
-
-
- -
- - - - diff --git a/lps-temp-fe/serve.sh b/lps-temp-fe/serve.sh deleted file mode 100755 index 81ac4d6..0000000 --- a/lps-temp-fe/serve.sh +++ /dev/null @@ -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 diff --git a/lps/README.md b/lps/README.md index 9008ff4..6113879 100644 --- a/lps/README.md +++ b/lps/README.md @@ -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) diff --git a/lps/common/alerts.py b/lps/common/alerts.py new file mode 100644 index 0000000..ed04375 --- /dev/null +++ b/lps/common/alerts.py @@ -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 diff --git a/lps/common/database/db_session_manager.py b/lps/common/database/db_session_manager.py index 82c18eb..78df507 100644 --- a/lps/common/database/db_session_manager.py +++ b/lps/common/database/db_session_manager.py @@ -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 되며 경고를 남긴다. diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index 78c0bce..2e96c5e 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -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당 평균 몇 요청 만에 감지되는지)""" diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index 469db4d..b287f7f 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -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분) diff --git a/lps/config/config_models.py b/lps/config/config_models.py index a8b913c..d4eb292 100644 --- a/lps/config/config_models.py +++ b/lps/config/config_models.py @@ -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 세션 수 임계 diff --git a/lps/config/server_configs.py b/lps/config/server_configs.py index 2e31bb7..1f9032a 100644 --- a/lps/config/server_configs.py +++ b/lps/config/server_configs.py @@ -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) diff --git a/lps/crud/bot_detection.py b/lps/crud/bot_detection.py index e1692f1..804ff62 100644 --- a/lps/crud/bot_detection.py +++ b/lps/crud/bot_detection.py @@ -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)") diff --git a/lps/crud/ip_session.py b/lps/crud/ip_session.py new file mode 100644 index 0000000..af66c6c --- /dev/null +++ b/lps/crud/ip_session.py @@ -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) diff --git a/lps/crud/job_crud.py b/lps/crud/job_crud.py index c221d24..db2b989 100644 --- a/lps/crud/job_crud.py +++ b/lps/crud/job_crud.py @@ -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) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index 9a40011..d4c74c9 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -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) diff --git a/lps/docs/api.md b/lps/docs/api.md index 028ab6e..accc3a8 100644 --- a/lps/docs/api.md +++ b/lps/docs/api.md @@ -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` 누락/불일치 — 키 주입 확인(상단 인증 참고) diff --git a/lps/docs/architecture.md b/lps/docs/architecture.md index 534ff1e..fc79bdc 100644 --- a/lps/docs/architecture.md +++ b/lps/docs/architecture.md @@ -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세트인가 (더 띄우면 안 되나?) diff --git a/lps/docs/database.md b/lps/docs/database.md index 4a4b8c9..7769a7b 100644 --- a/lps/docs/database.md +++ b/lps/docs/database.md @@ -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`로 자동 생성. diff --git a/lps/docs/decision-openmarket-crawler.md b/lps/docs/decision-openmarket-crawler.md index 69975ad..a6ec100 100644 --- a/lps/docs/decision-openmarket-crawler.md +++ b/lps/docs/decision-openmarket-crawler.md @@ -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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것. diff --git a/lps/docs/operations.md b/lps/docs/operations.md index d97104e..55ddc5c 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -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:` | 소스별 최근 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..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 캐시**가 다음 절감 후보. diff --git a/lps/loadtest/README.md b/lps/loadtest/README.md index 4a72cb0..0bad8eb 100644 --- a/lps/loadtest/README.md +++ b/lps/loadtest/README.md @@ -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**(커넥션 풀러) 도입 diff --git a/lps/migrations/2026-07-13-ip_session.sql b/lps/migrations/2026-07-13-ip_session.sql new file mode 100644 index 0000000..1ff4d5f --- /dev/null +++ b/lps/migrations/2026-07-13-ip_session.sql @@ -0,0 +1,17 @@ +-- IP(프록시 포트) 세션 종료 이력 — 요청 예산(LPS_IP_REQUEST_BUDGET) 상한 튜닝의 원천 데이터. +-- bot_detection 은 차단된 세션만 남지만, 여기엔 무사 종료(budget/window 등)도 남는다. +-- 적용: psql -h -U -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); diff --git a/lps/router/router.py b/lps/router/router.py index 93518e6..4a637c6 100644 --- a/lps/router/router.py +++ b/lps/router/router.py @@ -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.. 를 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 에 키 채움 + 포트 비공개 필수") diff --git a/lps/router/v1/lps/admin.py b/lps/router/v1/lps/admin.py new file mode 100644 index 0000000..6e00dd4 --- /dev/null +++ b/lps/router/v1/lps/admin.py @@ -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)) diff --git a/lps/router/v1/lps/admin_protocol.py b/lps/router/v1/lps/admin_protocol.py new file mode 100644 index 0000000..c0033fc --- /dev/null +++ b/lps/router/v1/lps/admin_protocol.py @@ -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) diff --git a/lps/router/v1/validator/auth.py b/lps/router/v1/validator/auth.py new file mode 100644 index 0000000..584b285 --- /dev/null +++ b/lps/router/v1/validator/auth.py @@ -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") diff --git a/lps/run_docker.sh b/lps/run_docker.sh new file mode 100755 index 0000000..8daee3c --- /dev/null +++ b/lps/run_docker.sh @@ -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 diff --git a/lps/run_local_server.sh b/lps/run_local_server.sh index f851156..75b6b75 100755 --- a/lps/run_local_server.sh +++ b/lps/run_local_server.sh @@ -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 ;; diff --git a/lps/run_local_worker.sh b/lps/run_local_worker.sh index f670adc..6993d44 100755 --- a/lps/run_local_worker.sh +++ b/lps/run_local_worker.sh @@ -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 "" diff --git a/lps/services/admin_service.py b/lps/services/admin_service.py new file mode 100644 index 0000000..4f9e22b --- /dev/null +++ b/lps/services/admin_service.py @@ -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 diff --git a/lps/services/lps_service.py b/lps/services/lps_service.py index 71876fb..47d29f8 100644 --- a/lps/services/lps_service.py +++ b/lps/services/lps_service.py @@ -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: diff --git a/lps/services/search/browser_base.py b/lps/services/search/browser_base.py index f2842b5..28d87b1 100644 --- a/lps/services/search/browser_base.py +++ b/lps/services/search/browser_base.py @@ -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 diff --git a/lps/services/search/contract.py b/lps/services/search/contract.py index 69eec9b..bd3076e 100644 --- a/lps/services/search/contract.py +++ b/lps/services/search/contract.py @@ -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 diff --git a/lps/services/search/naver/adapter.py b/lps/services/search/naver/adapter.py index 2995c24..799ef4a 100644 --- a/lps/services/search/naver/adapter.py +++ b/lps/services/search/naver/adapter.py @@ -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] diff --git a/lps/services/search/proxy.py b/lps/services/search/proxy.py index 977c1c4..3d67bc9 100644 --- a/lps/services/search/proxy.py +++ b/lps/services/search/proxy.py @@ -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): diff --git a/lps/tests/test_admin_api.py b/lps/tests/test_admin_api.py new file mode 100644 index 0000000..9205377 --- /dev/null +++ b/lps/tests/test_admin_api.py @@ -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 diff --git a/lps/tests/test_alerts.py b/lps/tests/test_alerts.py new file mode 100644 index 0000000..7ab0b9d --- /dev/null +++ b/lps/tests/test_alerts.py @@ -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 diff --git a/lps/tests/test_api_guard.py b/lps/tests/test_api_guard.py new file mode 100644 index 0000000..e6eb966 --- /dev/null +++ b/lps/tests/test_api_guard.py @@ -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 밖 diff --git a/lps/tests/test_browser_base.py b/lps/tests/test_browser_base.py index f370c96..8a6beaa 100644 --- a/lps/tests/test_browser_base.py +++ b/lps/tests/test_browser_base.py @@ -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] # 차단마다 해당 포트 쿨다운 격리 # ── 라이브 스모크(옵트인): 실제 사이트 셀렉터·안티봇 드리프트 감지 ────── diff --git a/lps/tests/test_ip_session.py b/lps/tests/test_ip_session.py new file mode 100644 index 0000000..8f34bf6 --- /dev/null +++ b/lps/tests/test_ip_session.py @@ -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} diff --git a/lps/tests/test_job_queue.py b/lps/tests/test_job_queue.py index 488bc22..b211dc6 100644 --- a/lps/tests/test_job_queue.py +++ b/lps/tests/test_job_queue.py @@ -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 diff --git a/lps/tests/test_lps_api.py b/lps/tests/test_lps_api.py index 3e659a3..faf8206 100644 --- a/lps/tests/test_lps_api.py +++ b/lps/tests/test_lps_api.py @@ -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 diff --git a/lps/tests/test_proxy.py b/lps/tests/test_proxy.py index 49cf61e..86f348b 100644 --- a/lps/tests/test_proxy.py +++ b/lps/tests/test_proxy.py @@ -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 만큼 diff --git a/lps/worker_main.py b/lps/worker_main.py index c0195fc..7af0cb7 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -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)) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index b50663d..b4fa86f 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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, 워터마크 기준) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 51eee43..69dd229 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -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): diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index 4bcf500..45549dd 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -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"] diff --git a/negodata/backend/crud/lps_sync_crud.py b/negodata/backend/crud/lps_sync_crud.py index ae00eb5..4d0ffeb 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -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: diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 17ae3b2..e00d98c 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -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 diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 9762067..2325ce1 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -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 diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 7d175ad..f5f166c 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 572a880..54d102b 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts b/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts new file mode 100644 index 0000000..7d7cb19 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryLpName.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts b/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts new file mode 100644 index 0000000..98402ed --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryLpUrl.ts @@ -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; diff --git a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx new file mode 100644 index 0000000..7d21e10 --- /dev/null +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -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 = { + 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 ( + +
+ {title} + {action} +
+ {children} +
+ ); +} + +// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. +// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. +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 ( + +
+ {/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */} +
+ {product.name} +
+ + {representative != null ? won(Number(representative)) : '수집 전'} + + {diff != null && ( + + {diff <= 0 ? : } + 단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))} + + )} +
+ + 상품 단가 {product.price != null ? won(product.price) : '-'} + {latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}} + +
+ + {/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */} + {latest && ( + {websiteLabel(latest.website)}} + > + {latest.lp_name ? ( + {latest.lp_name} + ) : ( + 출처 상세 미수집(이전 버전 수집분) + )} + {latest.lp_url && ( + + 판매 페이지에서 확인 + + )} + + )} + + {/* ── 가격 추이 ── */} + 0 ? ( + {successes.length}회 수집 + ) : undefined + } + > + {chartData.length >= 2 ? ( + + + + + Number(v).toLocaleString()} + fontSize={10} + /> + } /> + + + + ) : ( +
+ + + {successes.length === 1 + ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' + : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} + +
+ )} +
+ + {/* ── 수집 이력 ── */} + + {isLoading ? ( +
+ + 이력을 불러오는 중... +
+ ) : entries.length === 0 ? ( +
+ + + 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. + +
+ ) : ( +
    + {entries.map((e, i) => ( +
  • +
    + + {websiteLabel(e.website)} + + + {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'} + +
    +
    + {e.success_yn && e.lp_price != null ? ( + {won(e.lp_price)} + ) : ( + 미발견 + )} + {e.lp_url ? ( + + + + ) : ( + /* 링크 없는 행도 가격 우측 정렬 유지 */ + )} +
    +
  • + ))} +
+ )} +
+
+
+ ); +} + +// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만) +function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+ {timeLabel(p.t)} + {won(p.price)} +
+ ); +} diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index 355b1fd..c0cb526 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -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) => ( +
+ {prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'} + +
+ ), }, { header: label('creator'), diff --git a/negodata/front/src/pages/products.tsx b/negodata/front/src/pages/products.tsx index 896e623..fa75a1c 100644 --- a/negodata/front/src/pages/products.tsx +++ b/negodata/front/src/pages/products.tsx @@ -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([]); // 딥링크·뒤로가기·새로고침 지원 - 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 && ( + + )} + {modal === 'excel' && (