chore(phase0): eBay 세트 제거 — 문진·문서 admin/API/lib/CONTENT + cheerio

Delete:
- app/api/questionnaire/ (submit/route.ts)
- app/admin/questionnaire/ (page.tsx)
- app/api/admin/questionnaire/ (route.ts + [id]/route.ts)
- app/admin/documents/ (page.tsx)
- app/api/admin/documents/ ([filename]/route.ts)
- lib/ebay-tools/ (crawler.ts·pricing.ts·ai-analyzer.ts·types.ts)
- CONTENT/ebay-tool-{questionnaire,proposal}.html
- CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md

Modify:
- app/admin/components/AdminSidebar.tsx: Remove NAV_ITEMS for /admin/documents & /admin/questionnaire
- package.json: Remove cheerio dependency

Verify: npm test (4 files, 20 tests PASS), npm run build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:02:47 +09:00
parent 0c6ebb2eaa
commit 88fe56163d
16 changed files with 0 additions and 4904 deletions

View File

@@ -1,969 +0,0 @@
# 이베이 자동차 부품 리스팅 AI 자동화 툴 — 기술 아키텍처 설계서
> 작성일: 2026-04-02
> 작성자: Developer Agent (쟁승메이드)
> 버전: v1.0 Draft
---
## 목차
1. [시스템 아키텍처 설계](#1-시스템-아키텍처-설계)
2. [기술 스택 선정 및 근거](#2-기술-스택-선정-및-근거)
3. [핵심 모듈별 상세 설계](#3-핵심-모듈별-상세-설계)
4. [DB 스키마 설계](#4-db-스키마-설계)
5. [API 엔드포인트 설계](#5-api-엔드포인트-설계)
6. [계정 안전성 설계](#6-계정-안전성-설계)
7. [리스크 & 트레이드오프](#7-리스크--트레이드오프)
---
## 1. 시스템 아키텍처 설계
### 1.1 전체 시스템 구성도
```
┌──────────────────────────────────────────────────────────────────────┐
│ 클라이언트 (PC 브라우저) │
│ Next.js App Router — Tailwind CSS │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 부품 검색 │ │ 결과 대시 │ │ 리스팅 │ │ 히스토리 │ │
│ │ 입력 폼 │ │ 보드 │ │ 편집기 │ │ /설정 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────┬───────────────────────────────────────────┘
│ HTTPS (Vercel Edge)
┌──────────────────────────────────────────────────────────────────────┐
│ Next.js API Routes (Vercel Serverless) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ /api/ │ │ /api/ │ │ /api/ │ │ /api/ │ │
│ │ search │ │ analyze │ │ listing │ │ price │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼────────────┼────────────┼────────────┼─────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 크롤러 워커 (별도 서버 — Docker/VPS) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Playwright 엔진 │ │ 사이트별 어댑터 │ │ 프록시 로테이터 │ │
│ │ (브라우저 풀) │ │ RockAuto/Amazon │ │ + User-Agent 풀 │ │
│ │ │ │ PartsGeek/eBay │ │ │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BullMQ 큐 관리 │ │ Redis │ │
│ │ (작업 스케줄링) │ │ (캐시/큐 백엔드) │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ AI 분석 파이프라인 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Claude API │ │ 구조화 출력 파서 │ │ Fitment 검증기 │ │
│ │ (주 분석 엔진) │ │ (JSON Schema) │ │ (Cross-ref) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ 데이터 저장 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Supabase │ │ Redis Cache │ │
│ │ (PostgreSQL) │ │ (TTL 기반) │ │
│ │ - 검색 히스토리 │ │ - 크롤링 결과 │ │
│ │ - 부품 데이터 │ │ - 환율 캐시 │ │
│ │ - 리스팅 초안 │ │ - 세션 상태 │ │
│ │ - 사용자 설정 │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### 1.2 데이터 흐름도 (메인 파이프라인)
```
[사용자 입력]
품번: "16610-0H040"
품명: "Fuel Pump Assembly"
[1단계: 초기 검색 — 2~5초]
├─ Supabase 캐시 조회 (동일 품번 24시간 이내 검색 존재?)
│ ├─ HIT → 캐시 결과 즉시 반환 + 백그라운드 갱신 옵션 제공
│ └─ MISS → 크롤링 작업 생성
[2단계: 크롤링 큐 등록 — 즉시]
├─ BullMQ에 작업 등록
├─ 클라이언트에 jobId 반환 → SSE/Polling으로 진행률 추적
[3단계: 병렬 크롤링 — 15~45초]
├─ [Worker 1] RockAuto 검색 → 가격, 호환 차종, 이미지
├─ [Worker 2] PartsGeek 검색 → 가격, 리뷰 수
├─ [Worker 3] Amazon 검색 → 가격, 판매량 추정
├─ [Worker 4] eBay 기존 리스팅 검색 → 경쟁 가격, 판매량
├─ [Worker 5] OEM DB 검색 (partsouq) → 순정 번호, 호환 번호
└─ 각 Worker: 성공/실패 개별 보고, 부분 실패 허용
[4단계: AI 분석 — 5~15초]
├─ 수집 데이터 정규화 + 병합
├─ Claude API 호출 (구조화 출력 요청)
│ ├─ Fitment 매칭 (차종별 연도/모델/엔진)
│ ├─ 최적 리스팅 제목 생성 (80자 이내)
│ ├─ Item Specifics 추출
│ └─ 가격 추천 (시장가 분석 기반)
├─ 정확도 검증 (Cross-reference 체크)
[5단계: 가격 계산 — 1초]
├─ 환율 API (KRW/USD)
├─ 원가 + 관세(8%) + 국제배송비 + 이베이 수수료(13%) + 마진
├─ 경쟁 가격 대비 포지셔닝
[6단계: 리스팅 생성 — 즉시]
├─ eBay 리스팅 템플릿 조립
├─ Fitment Chart (Year/Make/Model/Engine 테이블)
├─ Supabase에 초안 저장
└─ 사용자에게 최종 결과 반환 (편집 가능)
```
### 1.3 배포 아키텍처
```
┌─────────────────────────────────┐
│ Vercel (프론트 + API) │
│ Next.js App Router │
│ - SSR 페이지 │
│ - API Routes (오케스트레이터) │
│ - Edge Functions (경량 API) │
│ maxDuration: 60s (Pro) │
└──────────────┬──────────────────┘
│ HTTPS
┌─────────────────────────────────┐
│ VPS (크롤러 전용 서버) │
│ Docker Compose │
│ │
│ ┌───────────┐ ┌──────────┐ │
│ │ crawler │ │ Redis │ │
│ │ (Node.js │ │ 7.x │ │
│ │ +Playwright│ │ │ │
│ │ +BullMQ) │ │ │ │
│ └───────────┘ └──────────┘ │
│ │
│ 비용: ~$10~20/월 (Contabo/ │
│ Hetzner 2vCPU/4GB) │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Supabase (DB + Auth) │
│ PostgreSQL + Row Level Security│
│ Free tier → Pro 필요 시 전환 │
└─────────────────────────────────┘
```
**배포 분리 이유:**
- Vercel Serverless는 최대 60초 타임아웃 (Pro). 크롤링은 45초 이상 소요 가능
- Playwright는 ~400MB 브라우저 바이너리 필요. Vercel 함수 크기 제한(50MB) 초과
- 크롤러 서버를 분리하면 IP 관리, 프록시 설정, 브라우저 풀 관리가 자유로움
- Vercel API Routes는 오케스트레이터 역할만 수행 (크롤러 서버에 작업 위임)
---
## 2. 기술 스택 선정 및 근거
### 2.1 프론트엔드
| 항목 | 선택 | 근거 |
|------|------|------|
| 프레임워크 | **Next.js 16 App Router** | 기존 jaengseung-made 스택 동일. SSR/ISR, API Routes 통합 |
| 스타일링 | **Tailwind CSS v4** | 기존 스택. 빠른 프로토타이핑, 일관된 디자인 시스템 |
| 상태 관리 | **React 19 내장 (useState/useReducer)** | 복잡한 글로벌 상태 불필요. 폼 + 결과 뷰 중심 |
| 실시간 갱신 | **SSE (Server-Sent Events)** | 크롤링 진행률 실시간 표시. WebSocket 대비 구현 단순 |
### 2.2 백엔드 (오케스트레이터)
| 항목 | 선택 | 근거 |
|------|------|------|
| API | **Next.js API Routes** | 별도 FastAPI 불필요. 크롤러만 분리하면 API Routes로 충분 |
| 인증 | **Supabase Auth** | 기존 jaengseung-made 인증 체계 재사용 |
| 비동기 통신 | **HTTP + SSE** | Vercel API → 크롤러 서버 HTTP 호출, 클라이언트에는 SSE로 진행률 전달 |
**FastAPI 별도 서버 검토 결과: 불채택**
- 크롤러 서버가 이미 분리되므로, API 오케스트레이션만 하는 레이어에 FastAPI를 또 세우면 인프라 복잡도만 증가
- Next.js API Routes + Vercel Serverless로 오케스트레이션 충분
- 단, 향후 사용량 급증 시 API 레이어 분리 고려 가능
### 2.3 크롤러 엔진
| 항목 | Playwright | Puppeteer |
|------|-----------|-----------|
| **브라우저 지원** | Chromium, Firefox, WebKit | Chromium only |
| **Anti-bot 우회** | stealth 플러그인 생태계 넓음 | puppeteer-extra-stealth 있음 |
| **안정성** | Microsoft 관리, 업데이트 빠름 | Google Chrome팀, 안정적 |
| **멀티 컨텍스트** | 브라우저 하나에 격리된 컨텍스트 다수 생성 가능 | 유사하나 API 덜 직관적 |
| **Docker 지원** | 공식 Docker 이미지 제공 | 수동 설정 필요 |
| **선택** | **Playwright** | - |
**Playwright 선택 이유:**
1. `browser.newContext()`로 사이트별 격리된 세션 관리 용이 (쿠키/스토리지 분리)
2. `playwright-extra` + `stealth` 플러그인으로 headless 탐지 우회 성숙
3. 자동 대기(`waitForSelector`, `waitForLoadState`) API가 크롤링에 최적화
4. Firefox 컨텍스트를 섞어 쓸 수 있어 fingerprint 다양화 가능
### 2.4 AI 엔진
| 항목 | Claude API (Anthropic) | OpenAI API |
|------|----------------------|------------|
| **구조화 출력** | Tool Use로 JSON Schema 강제 가능 | JSON Mode / Function Calling |
| **긴 컨텍스트** | 200K 토큰 (크롤링 데이터 대량 입력에 유리) | 128K (GPT-4o) |
| **정확도** | 복잡한 추론/분류에 강점 | 범용적으로 우수 |
| **비용 (입/출력)** | Sonnet: $3/$15 per 1M tok | GPT-4o: $2.5/$10 per 1M tok |
| **기존 의존성** | jaengseung-made에 `@anthropic-ai/sdk` 이미 설치 | `openai` 패키지도 설치됨 |
| **선택** | **Claude API (주)** + OpenAI (폴백) | - |
**Claude 선택 이유:**
1. 200K 컨텍스트 윈도우 — 5개 사이트 크롤링 결과를 한 번에 분석 가능
2. Tool Use 기반 구조화 출력 — Fitment 테이블, Item Specifics 등 복잡한 JSON 구조 강제
3. 자동차 부품 도메인의 정밀한 분류/추론에서 강점 (호환 차종 판단은 환각 최소화 중요)
4. 기존 프로젝트에 SDK 설치됨 — 추가 의존성 없음
**비용 추정 (건당):**
- 입력: ~8K 토큰 (5개 사이트 크롤링 결과 요약) = ~$0.024
- 출력: ~2K 토큰 (구조화된 리스팅 정보) = ~$0.030
- **건당 약 $0.05~0.06 (약 70~80원)**
### 2.5 큐/비동기 처리
| 항목 | 선택 | 근거 |
|------|------|------|
| 작업 큐 | **BullMQ** | Node.js 네이티브, Redis 기반, 재시도/우선순위/스케줄링 내장 |
| 큐 백엔드 | **Redis 7** | BullMQ 필수. 크롤링 결과 TTL 캐시 겸용 |
| 대안 검토 | ~~RabbitMQ~~ | 오버스펙. Node.js 단일 언어 환경에서 BullMQ가 최적 |
| 대안 검토 | ~~Vercel Queue~~ | 아직 베타, 커스텀 재시도 로직 제한적 |
**BullMQ 작업 흐름:**
```
Vercel API → HTTP POST → 크롤러 서버 /jobs 엔드포인트
→ BullMQ 큐에 작업 등록
→ Worker가 Playwright로 크롤링 실행
→ 완료 시 Redis에 결과 저장 + Webhook/SSE로 Vercel에 통지
→ Vercel API가 클라이언트 SSE로 결과 전달
```
### 2.6 기술 스택 종합표
| 레이어 | 기술 | 비용 |
|--------|------|------|
| 프론트엔드 | Next.js 16 + Tailwind v4 + React 19 | Vercel Free/Pro |
| API 오케스트레이터 | Next.js API Routes (Vercel Serverless) | Vercel에 포함 |
| 크롤러 서버 | Node.js + Playwright + BullMQ | VPS $10~20/월 |
| 캐시/큐 | Redis 7 (Docker) | VPS에 포함 |
| AI | Claude API (Anthropic) | ~$0.05/건 |
| DB | Supabase (PostgreSQL) | Free → Pro |
| 환율 | ExchangeRate-API 또는 한국은행 API | 무료 |
| 배포 | Vercel (프론트) + Docker Compose (크롤러) | 합계 ~$15~25/월 |
---
## 3. 핵심 모듈별 상세 설계
### 3.1 크롤러 모듈
#### 아키텍처: 어댑터 패턴
각 대상 사이트를 독립된 어댑터로 구현. 공통 인터페이스를 통해 결과를 정규화.
```
CrawlerOrchestrator
├── RockAutoAdapter (가격, 호환차종, 이미지)
├── PartsGeekAdapter (가격, 리뷰)
├── AmazonAdapter (가격, 판매량)
├── EbaySearchAdapter (경쟁 리스팅, 판매량, 가격)
├── PartsouqAdapter (OEM 번호, 호환 번호, 차종)
└── (확장 가능: AutoZone, 7zap 등)
```
#### 사이트별 크롤링 전략
| 사이트 | 방식 | 난이도 | 핵심 데이터 | 비고 |
|--------|------|--------|------------|------|
| **RockAuto** | Playwright (동적 렌더링) | 중 | 가격, Fitment, 이미지 URL | 카테고리 네비게이션 필요 |
| **PartsGeek** | HTTP + HTML 파싱 | 하 | 가격, 리뷰 수 | 정적 HTML, 단순 파싱 가능 |
| **Amazon** | Playwright (봇 감지 강함) | 상 | 가격, BSR, 리뷰 | CAPTCHA 빈번, 폴백 필요 |
| **eBay** | **eBay Browse API (공식)** | 하 | 경쟁가, 판매량, 카테고리 | API 우선, 크롤링 최소화 |
| **partsouq** | HTTP + JSON API | 중 | OEM 번호, 호환 번호 | 내부 API 엔드포인트 활용 |
#### 공통 어댑터 인터페이스
```
Input:
- partNumber: string (품번)
- partName: string (품명, 영문)
- options?: { timeout, proxy, userAgent }
Output (정규화):
- source: string (사이트명)
- status: "success" | "partial" | "failed"
- products: Array<{
title: string
price: { amount: number, currency: "USD" | "KRW" }
imageUrls: string[]
brand: string
oemNumbers: string[] (호환 품번)
fitment: Array<{ year: string, make: string, model: string, engine?: string }>
url: string
reviews?: { count: number, rating: number }
salesRank?: number
}>
- metadata: { crawledAt: ISO8601, responseTime: number }
- error?: string
```
#### Rate Limiting
| 사이트 | 요청 간격 | 일일 한도 | 근거 |
|--------|----------|----------|------|
| RockAuto | 3~5초 (랜덤) | 200회 | 공격적 봇 감지 |
| PartsGeek | 1~2초 | 500회 | 상대적 관대 |
| Amazon | 5~10초 (랜덤) | 100회 | CAPTCHA 트리거 방지 |
| eBay | API Rate Limit 준수 | 5000 calls/day | 공식 API 사용 |
| partsouq | 2~3초 | 300회 | 내부 API 부하 방지 |
#### 캐싱 전략
- **Redis TTL 캐시**: 동일 품번 크롤링 결과를 24시간 캐시
- **캐시 키**: `crawl:{site}:{partNumber}` (예: `crawl:rockauto:16610-0H040`)
- **캐시 히트 시**: 즉시 반환 + "갱신" 버튼으로 수동 리크롤 가능
- **Supabase 장기 캐시**: 30일간 부품 마스터 데이터 (OEM 번호, Fitment) 보관
#### 차단 대응 (폴백 계층)
```
1차: Playwright + Stealth 플러그인 (기본)
↓ 차단 감지 시
2차: 프록시 로테이션 (주거용 프록시 풀)
↓ 차단 지속 시
3차: 해당 사이트 스킵 + 나머지 사이트 결과로 분석 진행
↓ 핵심 사이트(eBay) 차단 시
4차: eBay 공식 API로 폴백 (Browse API / Finding API)
```
#### 에러 처리
- 각 어댑터는 독립 실행. 1개 사이트 실패해도 나머지 정상 진행
- 최소 2개 사이트 성공 시 AI 분석 진행 가능
- 전체 실패 시: 사용자에게 수동 입력 폼 제공 (URL 붙여넣기)
---
### 3.2 AI 분석 모듈
#### 프롬프트 설계 방향
**System Instruction (고정)**:
```
역할: 자동차 부품 이베이 리스팅 전문가
- 입력된 크롤링 데이터를 분석하여 이베이 리스팅 정보를 생성
- Fitment 정보는 반드시 크롤링 데이터에서 확인된 차종만 포함 (추측 금지)
- 이베이 Title은 80자 이내, 핵심 키워드 우선 배치
- Item Specifics는 eBay Motors Parts & Accessories 카테고리 기준
```
**User Message (동적 — 크롤링 결과 포함)**:
```
품번: {partNumber}
품명: {partName}
[크롤링 결과]
--- RockAuto ---
{rockAutoData}
--- eBay 경쟁 리스팅 ---
{ebayData}
--- OEM DB ---
{oemData}
위 데이터를 분석하여 다음을 생성해주세요:
1. 이베이 최적 제목 (3개 후보)
2. Item Specifics
3. Fitment Chart
4. 가격 추천
```
#### 구조화 출력 (Tool Use Schema)
Claude API의 Tool Use를 활용하여 JSON 구조를 강제:
```
Tool Name: generate_ebay_listing
Parameters Schema:
{
titles: string[3] // 제목 후보 3개
recommendedTitle: string // 추천 제목 (80자 이내)
category: {
id: number // eBay 카테고리 ID
name: string // 카테고리명
}
itemSpecifics: {
brand: string
manufacturerPartNumber: string
interchangePartNumber: string // OE/OEM 호환 번호
placement: string // "Front", "Rear" 등
type: string // 부품 유형
material: string
color: string
warranty: string
country: string // 제조국
[key: string]: string // 추가 Specifics
}
fitment: Array<{
year: string // "2007-2012" 범위 가능
make: string // "Toyota"
model: string // "Camry"
engine: string // "2.4L L4"
trim?: string // "LE, SE, XLE"
notes?: string // 특이사항
}>
priceAnalysis: {
competitorAvg: number // 경쟁 평균가 (USD)
competitorRange: [number, number] // 최저~최고
recommendedPrice: number // 추천 판매가
reasoning: string // 가격 근거
}
description: string // HTML 상품 설명
confidence: {
fitment: "high" | "medium" | "low"
pricing: "high" | "medium" | "low"
overall: "high" | "medium" | "low"
}
warnings: string[] // 주의사항 (불확실한 정보 등)
}
```
#### 정확도 검증 (Multi-source Cross-reference)
1. **Fitment 교차 검증**: 2개 이상 소스에서 확인된 차종만 "high confidence"
2. **OEM 번호 검증**: partsouq/7zap 데이터와 크롤링 결과 대조
3. **가격 이상치 감지**: 경쟁 평균 대비 +-50% 이상 차이나면 경고
4. **confidence 레벨**:
- `high`: 3개 이상 소스 일치
- `medium`: 2개 소스 일치
- `low`: 1개 소스만 확인 → 사용자에게 수동 확인 요청
#### AI 폴백 전략
```
1차: Claude Sonnet 4 (비용 효율 + 정확도 밸런스)
↓ 실패/타임아웃 시
2차: Claude Haiku (빠른 응답, 약간의 정확도 트레이드오프)
↓ Anthropic API 장애 시
3차: OpenAI GPT-4o (폴백)
```
---
### 3.3 가격 계산 모듈
#### Input/Output
```
Input:
- sourcePrices: Array<{ source, price, currency }> // 크롤링된 가격들
- competitorPrices: Array<{ price, soldCount }> // eBay 경쟁 가격
- userSettings: { marginPercent, shippingMethod, customsRate }
Output:
- costBreakdown: {
purchasePrice: number (USD) // 구매가 (최저가 기준)
exchangeRate: number // 적용 환율
purchasePriceKRW: number // 원화 구매가
customsDuty: number (KRW) // 관세 (8% 기본)
customsTax: number (KRW) // 부가세 (10%)
domesticShipping: number (KRW) // 국내 배송비
intlShipping: number (USD) // 국제 배송비
ebayFee: number (USD) // eBay 수수료 (13.25%)
paypalFee: number (USD) // PayPal 수수료 (3.49% + $0.49)
totalCost: number (USD) // 총 원가
}
- pricing: {
breakEvenPrice: number (USD) // 손익분기점
recommendedPrice: number (USD) // 추천가 (마진 반영)
competitorAvg: number (USD) // 경쟁 평균
marginPercent: number // 예상 마진율
profitPerUnit: number (USD) // 건당 예상 수익
}
- comparison: Array<{ source, price, diff }> // 소스별 가격 비교표
```
#### 환율 처리
- **주 API**: ExchangeRate-API (무료 1,500회/월) 또는 한국은행 Open API
- **캐시**: Redis에 1시간 TTL로 환율 캐시
- **폴백**: 캐시 만료 + API 장애 시 최근 캐시값 사용 (24시간 이내)
- **사용자 수동 입력**: 환율 직접 입력 옵션 제공
#### 관세/수수료 테이블
| 항목 | 기본값 | 사용자 조정 가능 | 비고 |
|------|--------|----------------|------|
| 관세율 | 8% | O | 자동차 부품 HS Code 기준 |
| 부가세 | 10% | X | 고정 |
| eBay Final Value Fee | 13.25% | O | 카테고리별 상이 |
| PayPal/Managed Payments | 3.49% + $0.49 | O | 결제 방식별 상이 |
| 국제 배송비 | 무게 기반 계산 | O | EMS/K-Packet/FedEx 선택 |
| 이베이 프로모션 할인 | 0% | O | Promoted Listings 비용 |
---
### 3.4 리스팅 생성 모듈
#### eBay 카테고리 매핑
주요 자동차 부품 카테고리 매핑 테이블 (DB 저장):
| 부품 유형 | eBay Category ID | Category Path |
|-----------|-----------------|---------------|
| Fuel Pump | 33554 | eBay Motors > Parts > Fuel System > Fuel Pumps |
| Brake Pad | 33560 | eBay Motors > Parts > Brakes > Pads & Shoes |
| Air Filter | 33548 | eBay Motors > Parts > Air Intake > Filters |
| ... | ... | 약 200개 주요 카테고리 사전 매핑 |
- AI가 품명 기반으로 1차 카테고리 추천
- 사전 매핑 테이블과 교차 검증
- 사용자가 최종 선택/수정 가능
#### Item Specifics 템플릿
카테고리별 필수/선택 Item Specifics 템플릿:
```
[공통 필수]
- Brand
- Manufacturer Part Number
- Interchange Part Number
- Placement on Vehicle
- Warranty
- Country/Region of Manufacture
- UPC (없으면 "Does Not Apply")
[카테고리별 추가]
- Fuel Pump: Fuel Type, Number of Outlets, Voltage
- Brake Pad: Position (Front/Rear), Pad Material, Thickness
```
#### Fitment 테이블 출력 형식
eBay Parts Compatibility 형식에 맞춘 CSV/테이블:
```
Year | Make | Model | Trim | Engine | Notes
2007 | Toyota | Camry | LE, SE, XLE | 2.4L L4 DOHC |
2008 | Toyota | Camry | LE, SE, XLE | 2.4L L4 DOHC |
2008 | Toyota | Camry | SE, XLE | 3.5L V6 DOHC |
...
```
- eBay의 ePID (Product ID) 매칭 시도 (정확한 Fitment 보장)
- CSV 다운로드 기능 (eBay Bulk Upload용)
- 수동 행 추가/삭제 편집 기능
#### 최종 출력 형태
사용자에게 보여지는 리스팅 프리뷰:
```
[복사 가능 영역]
Title: [편집 가능]
Category: [드롭다운 선택]
Item Specifics: [테이블 형태, 각 필드 편집 가능]
Price: [입력 필드, 원가 계산기 연동]
Fitment Chart: [테이블, 행 추가/삭제 가능]
Description: [HTML 프리뷰 + 편집]
[액션 버튼]
- "전체 복사" (클립보드)
- "CSV 다운로드" (Fitment)
- "초안 저장" (Supabase)
- "히스토리에서 불러오기"
```
---
## 4. DB 스키마 설계 (Supabase / PostgreSQL)
### 4.1 테이블 구조
```sql
-- 사용자 설정
CREATE TABLE user_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
default_margin_percent DECIMAL(5,2) DEFAULT 30.00,
default_shipping_method TEXT DEFAULT 'k-packet',
default_customs_rate DECIMAL(5,2) DEFAULT 8.00,
ebay_fee_percent DECIMAL(5,2) DEFAULT 13.25,
preferred_currency TEXT DEFAULT 'USD',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id)
);
-- 검색 히스토리
CREATE TABLE search_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
part_number TEXT NOT NULL,
part_name TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending, crawling, analyzing, completed, failed
crawl_sources JSONB, -- 어떤 사이트를 크롤링했는지
result_summary JSONB, -- 요약 정보 (가격 범위, 호환 차종 수 등)
created_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_search_history_user ON search_history(user_id, created_at DESC);
CREATE INDEX idx_search_history_part ON search_history(part_number);
-- 부품 캐시 (크롤링 결과 장기 보관)
CREATE TABLE parts_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_number TEXT NOT NULL,
source TEXT NOT NULL, -- 'rockauto', 'partsgeek', 'amazon', 'ebay', 'partsouq'
raw_data JSONB NOT NULL, -- 크롤링 원본 데이터
normalized_data JSONB NOT NULL, -- 정규화된 데이터
crawled_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ DEFAULT (now() + INTERVAL '30 days'),
UNIQUE(part_number, source)
);
CREATE INDEX idx_parts_cache_lookup ON parts_cache(part_number, source, expires_at);
-- OEM 번호 매핑 (장기 캐시, 잘 변하지 않는 데이터)
CREATE TABLE oem_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_number TEXT NOT NULL,
oem_numbers TEXT[] NOT NULL, -- 호환 OEM 번호 배열
brands TEXT[], -- 관련 브랜드
fitment JSONB, -- 호환 차종 데이터
source TEXT NOT NULL, -- 데이터 출처
verified BOOLEAN DEFAULT false, -- 교차 검증 완료 여부
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_oem_part ON oem_mappings(part_number);
CREATE INDEX idx_oem_numbers ON oem_mappings USING GIN(oem_numbers);
-- 리스팅 초안
CREATE TABLE listing_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
search_id UUID REFERENCES search_history(id) ON DELETE SET NULL,
part_number TEXT NOT NULL,
title TEXT NOT NULL,
category_id INTEGER,
category_name TEXT,
item_specifics JSONB NOT NULL, -- { brand, mpn, ... }
fitment JSONB, -- [{ year, make, model, engine, trim }]
price_data JSONB, -- 가격 계산 결과 전체
description_html TEXT, -- HTML 상품 설명
ai_confidence JSONB, -- { fitment, pricing, overall }
ai_warnings TEXT[], -- AI가 제시한 경고사항
status TEXT DEFAULT 'draft', -- draft, published, archived
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_drafts_user ON listing_drafts(user_id, created_at DESC);
-- eBay 카테고리 매핑 (사전 정의)
CREATE TABLE ebay_categories (
id SERIAL PRIMARY KEY,
category_id INTEGER UNIQUE NOT NULL,
category_name TEXT NOT NULL,
category_path TEXT NOT NULL,
required_specifics TEXT[], -- 필수 Item Specifics 필드명
optional_specifics TEXT[], -- 선택 Item Specifics 필드명
keywords TEXT[] -- 매칭용 키워드
);
CREATE INDEX idx_ebay_cat_keywords ON ebay_categories USING GIN(keywords);
-- 환율 캐시
CREATE TABLE exchange_rates (
id SERIAL PRIMARY KEY,
base_currency TEXT NOT NULL DEFAULT 'USD',
target_currency TEXT NOT NULL DEFAULT 'KRW',
rate DECIMAL(12,4) NOT NULL,
fetched_at TIMESTAMPTZ DEFAULT now()
);
```
### 4.2 RLS (Row Level Security) 정책
```sql
-- 사용자별 데이터 격리
ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE search_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE listing_drafts ENABLE ROW LEVEL SECURITY;
-- 본인 데이터만 접근
CREATE POLICY "users_own_settings" ON user_settings
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "users_own_searches" ON search_history
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "users_own_drafts" ON listing_drafts
FOR ALL USING (auth.uid() = user_id);
-- 캐시 데이터는 모든 인증 사용자 읽기 가능
ALTER TABLE parts_cache ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated_read_cache" ON parts_cache
FOR SELECT USING (auth.role() = 'authenticated');
ALTER TABLE oem_mappings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated_read_oem" ON oem_mappings
FOR SELECT USING (auth.role() = 'authenticated');
```
---
## 5. API 엔드포인트 설계
### 5.1 Vercel API Routes (오케스트레이터)
#### 검색/크롤링
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/search` | 부품 검색 시작 | `{ partNumber, partName, sources?: string[] }` | `{ jobId, status: "queued", estimatedTime }` |
| GET | `/api/parts/search/[jobId]` | 검색 상태 조회 | - | `{ status, progress: { total, completed, failed }, partialResults? }` |
| GET | `/api/parts/search/[jobId]/stream` | SSE 실시간 진행률 | - | SSE: `{ event, data: { source, status, result? } }` |
#### AI 분석
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/analyze` | AI 분석 실행 | `{ jobId }` 또는 `{ crawlResults }` | `{ listing, confidence, warnings }` |
| POST | `/api/parts/analyze/regenerate` | AI 재분석 (특정 섹션) | `{ jobId, sections: ["title", "fitment"] }` | 해당 섹션만 재생성 |
#### 가격 계산
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/price/calculate` | 가격 계산 | `{ purchasePrice, currency, weight?, settings? }` | `{ costBreakdown, pricing, comparison }` |
| GET | `/api/exchange-rate` | 현재 환율 | `?base=USD&target=KRW` | `{ rate, fetchedAt }` |
#### 리스팅 관리
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/listings/drafts` | 초안 저장 | `{ ...listingData }` | `{ id, createdAt }` |
| GET | `/api/listings/drafts` | 초안 목록 | `?page=1&limit=20` | `{ drafts[], total }` |
| GET | `/api/listings/drafts/[id]` | 초안 상세 | - | `{ ...listingData }` |
| PUT | `/api/listings/drafts/[id]` | 초안 수정 | `{ ...updates }` | `{ ...updated }` |
| DELETE | `/api/listings/drafts/[id]` | 초안 삭제 | - | `{ success }` |
| GET | `/api/listings/drafts/[id]/csv` | Fitment CSV 다운로드 | - | CSV 파일 |
#### 히스토리
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| GET | `/api/parts/history` | 검색 히스토리 | `?page=1&limit=20` | `{ searches[], total }` |
| DELETE | `/api/parts/history/[id]` | 히스토리 삭제 | - | `{ success }` |
#### 설정
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| GET | `/api/settings` | 사용자 설정 조회 | - | `{ ...settings }` |
| PUT | `/api/settings` | 사용자 설정 수정 | `{ marginPercent?, shippingMethod?, ... }` | `{ ...updated }` |
### 5.2 크롤러 서버 내부 API (VPS)
Vercel -> 크롤러 서버 간 내부 통신. API Key 인증.
| Method | Path | 설명 |
|--------|------|------|
| POST | `/jobs` | 크롤링 작업 등록 |
| GET | `/jobs/[id]` | 작업 상태 조회 |
| GET | `/jobs/[id]/result` | 작업 결과 조회 |
| DELETE | `/jobs/[id]` | 작업 취소 |
| GET | `/health` | 헬스체크 |
인증: `Authorization: Bearer {CRAWLER_API_KEY}` (환경변수)
---
## 6. 계정 안전성 설계
### 6.1 기본 원칙
```
"크롤링은 탐지되지 않는 것이 아니라, 사람처럼 보이는 것이 목표"
```
### 6.2 계층별 방어 전략
#### Layer 1: 브라우저 핑거프린트 위장
| 대책 | 구현 |
|------|------|
| Stealth 플러그인 | `playwright-extra` + `stealth` 플러그인 (WebGL, WebRTC, Navigator 위장) |
| User-Agent 로테이션 | 실제 Chrome/Firefox UA 풀 (50개+), 세션 단위 고정 |
| Viewport 다양화 | 1920x1080, 1366x768, 1440x900 등 실제 해상도 랜덤 선택 |
| 언어/타임존 | `en-US`, `America/New_York` 등 일관된 프로필 |
| WebDriver 플래그 | `navigator.webdriver = false` 강제 |
#### Layer 2: 행동 패턴 모방
| 대책 | 구현 |
|------|------|
| 요청 간격 | 가우시안 분포 랜덤 딜레이 (평균 3초, 표준편차 1.5초) |
| 스크롤 시뮬레이션 | 페이지 로드 후 자연스러운 스크롤 (즉시 파싱 방지) |
| 마우스 무브먼트 | 클릭 전 마우스 이동 궤적 시뮬레이션 |
| 세션 관리 | 쿠키 유지, 세션 간 일관된 행동 |
| 접속 패턴 | 업무 시간대(미국 EST 9-17시) 집중, 심야 크롤링 최소화 |
#### Layer 3: IP/네트워크 관리
| 대책 | 구현 |
|------|------|
| 프록시 풀 | 주거용(Residential) 프록시 10개+ (Bright Data 또는 Oxylabs) |
| IP 로테이션 | 사이트별 세션 단위로 IP 고정 (세션 중 변경 금지) |
| 지역 설정 | 미국 IP만 사용 (부품 사이트 타겟 시장) |
| 프록시 비용 | 약 $15~30/월 (트래픽 기반 과금) |
#### Layer 4: eBay 특별 보호
```
[최우선 원칙] eBay는 크롤링 최소화. 공식 API 최대 활용.
- eBay Browse API: 리스팅 검색, 가격 조회 (공식)
- eBay Finding API: 카테고리 검색 (공식)
- 크롤링은 API로 불가능한 데이터만 (판매 완료 건수 등)
- eBay 크롤링 시 별도 IP + 최소 빈도 (일 50회 이하)
- 이베이 셀러 계정과 크롤링 IP를 절대 동일하게 사용하지 않음
```
#### Layer 5: 차단 감지 및 자동 중단
```
감지 신호:
- HTTP 403/429 응답
- CAPTCHA 페이지 감지 (특정 DOM 요소)
- CloudFlare Challenge 페이지
- 비정상적으로 빈 응답
대응:
1. 즉시 해당 사이트 크롤링 중단
2. 30분 쿨다운 (해당 사이트만)
3. 다른 프록시로 재시도 (1회)
4. 실패 시 해당 사이트 24시간 차단 + 관리자 알림
```
---
## 7. 리스크 & 트레이드오프
### 7.1 기술 선택 트레이드오프
| 선택 | 장점 | 단점 | 대안 |
|------|------|------|------|
| **크롤러 별도 VPS** | 타임아웃 제약 없음, IP 관리 자유 | 인프라 비용 + 관리 부담 | Vercel에서 직접 크롤링 (불가, 60초 제한) |
| **BullMQ + Redis** | 재시도/우선순위 내장, 모니터링 UI | Redis 추가 인프라 | DB 폴링 방식 (단순하지만 비효율) |
| **Playwright** | 다양한 브라우저, 스텔스 생태계 | 메모리 사용량 높음 (~400MB/인스턴스) | Puppeteer (더 가벼우나 Chromium only) |
| **Claude AI** | 긴 컨텍스트, 정밀한 추론 | OpenAI 대비 약간 비쌈 | GPT-4o (더 저렴, 컨텍스트 128K) |
| **SSE** | 단방향 실시간, 구현 단순 | 양방향 불가 | WebSocket (오버스펙), Polling (지연) |
| **Supabase** | 기존 스택, RLS, Auth 통합 | 고빈도 쓰기 시 비용 증가 | 자체 PostgreSQL (관리 부담) |
### 7.2 크롤링 차단 시 폴백 전략
```
[시나리오별 대응]
1. 단일 사이트 일시 차단 (가장 빈번)
→ 해당 사이트 스킵, 나머지로 분석 진행
→ AI가 "데이터 불충분" 경고 출력
2. 다수 사이트 동시 차단
→ 사용자에게 수동 URL 입력 폼 제공
→ 사용자가 브라우저에서 직접 검색한 URL을 붙여넣으면 파싱
3. eBay API 쿼터 소진
→ 일일 5000회 제한 모니터링
→ 90% 도달 시 캐시 우선 정책으로 전환
→ 100% 시 eBay 검색 링크만 제공 (수동 조회)
4. 장기 차단 (IP 블랙리스트)
→ 프록시 풀 교체
→ 최악의 경우 해당 사이트 어댑터 비활성화
→ 비크롤링 대안: 공식 API가 있는 사이트로 점진적 전환
```
### 7.3 AI 비용 추정
| 사용량 | 월 검색 건수 | AI 비용 | 크롤러 VPS | 프록시 | 합계 |
|--------|------------|---------|-----------|--------|------|
| 초기 (테스트) | 50건 | ~$3 | $10 | $0 (무프록시 테스트) | ~$13/월 |
| 소규모 운영 | 300건 | ~$18 | $15 | $15 | ~$48/월 |
| 중규모 운영 | 1,000건 | ~$60 | $20 | $30 | ~$110/월 |
| 대규모 | 3,000건+ | ~$180 | $40 | $50 | ~$270/월 |
### 7.4 개발 우선순위 제안 (MVP → 풀 버전)
#### Phase 1 — MVP (2~3주)
- 품번 입력 → RockAuto + eBay API만 크롤링
- Claude AI 분석 → 리스팅 제목 + Item Specifics 생성
- 가격 계산기 (수동 입력 기반)
- 크롤러: Vercel 자체 실행 (단순 HTTP 파싱 위주, Playwright 불필요)
- DB: Supabase에 검색 히스토리만
#### Phase 2 — 크롤러 분리 (2주)
- VPS에 Playwright + BullMQ 크롤러 서버 구축
- PartsGeek, Amazon, partsouq 어댑터 추가
- Redis 캐싱 도입
- SSE 실시간 진행률
#### Phase 3 — 고도화 (2주)
- Fitment 교차 검증 + confidence 시스템
- 프록시 로테이션 + 스텔스 강화
- 리스팅 초안 저장/편집/히스토리
- CSV 다운로드 (eBay Bulk Upload)
#### Phase 4 — 확장 (지속)
- eBay Listing API 직접 연동 (리스팅 자동 등록)
- 가격 모니터링 (경쟁 가격 변동 알림)
- 대량 처리 (CSV 품번 목록 일괄 검색)
- 사용자 통계 대시보드
### 7.5 핵심 리스크 목록
| 리스크 | 확률 | 영향 | 대응 |
|--------|------|------|------|
| 크롤링 대상 사이트 구조 변경 | 높음 (분기 1회) | 중 | 어댑터 패턴으로 격리, 모니터링 알림 |
| eBay 계정 제재 (잘못된 Fitment) | 중 | 상 | AI confidence 시스템, 수동 확인 권고 |
| AI 환각 (존재하지 않는 차종 생성) | 중 | 상 | Multi-source 교차 검증, low confidence 경고 |
| 크롤링 IP 차단 | 높음 | 중 | 프록시 풀, API 우선 전략 |
| AI API 비용 초과 | 낮음 | 중 | 캐시 적극 활용, Haiku 폴백 |
| Vercel 타임아웃 (60초) | 중 | 중 | 크롤러 서버 분리 (Phase 2) |
---
## 부록: 쟁승메이드 서비스 연계
이 프로젝트는 jaengseung-made.com의 **외주 개발 포트폴리오****업무 자동화 서비스** 레퍼런스로 활용:
- `/freelance` 포트폴리오에 "이베이 자동화 툴" 케이스 추가
- `/services/automation` 페이지에서 "해외 이커머스 자동화" 사례로 소개
- 동일 기술 스택(Next.js + Supabase + AI)으로 일관된 개발 역량 시연
- 향후 SaaS화 시 쟁승메이드 구독 서비스로 편입 가능
---
> 이 문서는 초안이며, CEO(박재오) 리뷰 후 Phase 1 착수 전에 확정합니다.

File diff suppressed because it is too large Load Diff

View File

@@ -1,973 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이베이 자동차 부품 AI 리스팅 자동화 — 사전 요구사항 질문지</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
color: #1e293b;
background: #f8fafc;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
max-width: 800px;
margin: 0 auto;
background: #ffffff;
padding: 48px 56px;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #1a56db;
padding-bottom: 20px;
margin-bottom: 12px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
width: 44px;
height: 44px;
background: #1a56db;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-icon svg {
width: 26px;
height: 26px;
fill: #ffffff;
}
.brand-name {
font-size: 22px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 12px;
font-weight: 400;
color: #64748b;
}
.doc-date {
font-size: 13px;
color: #94a3b8;
text-align: right;
}
.doc-title {
font-size: 19px;
font-weight: 700;
color: #0f172a;
margin-bottom: 4px;
line-height: 1.4;
}
.doc-subtitle {
font-size: 14px;
color: #64748b;
margin-bottom: 32px;
}
/* ── Client Info ── */
.client-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 36px;
}
.client-info .field {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.client-info .field-label {
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.client-info .field-value {
flex: 1;
border-bottom: 1px solid #cbd5e1;
min-height: 24px;
padding-bottom: 2px;
}
.client-info .field-input {
flex: 1;
border: none;
border-bottom: 1px solid #cbd5e1;
min-height: 24px;
padding: 2px 4px;
font-family: inherit;
font-size: 14px;
color: #1e293b;
background: transparent;
outline: none;
transition: border-color 0.2s;
}
.client-info .field-input:focus {
border-bottom-color: #1a56db;
}
.client-info .field-input::placeholder {
color: #cbd5e1;
}
/* ── Submit Section ── */
.submit-section {
margin-top: 32px;
text-align: center;
}
.submit-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 48px;
background: #1a56db;
color: #ffffff;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover {
background: #1e40af;
}
.submit-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.submit-msg {
margin-top: 12px;
font-size: 14px;
line-height: 1.6;
}
.submit-msg.success {
color: #16a34a;
}
.submit-msg.error {
color: #dc2626;
}
.save-draft-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 24px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-right: 12px;
transition: background 0.2s;
}
.save-draft-btn:hover {
background: #e2e8f0;
}
/* ── Section ── */
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.section-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
flex-shrink: 0;
}
.badge-required {
background: #1a56db;
color: #ffffff;
}
.badge-optional {
background: #e2e8f0;
color: #475569;
}
.section-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
font-size: 13px;
color: #64748b;
margin-bottom: 16px;
padding-left: 2px;
}
/* ── Question Card ── */
.question {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
page-break-inside: avoid;
}
.question:last-child {
margin-bottom: 0;
}
.q-top {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.q-num {
width: 28px;
height: 28px;
background: #1a56db;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
.badge-optional + .section-title ~ .question .q-num,
.section.optional .q-num {
background: #64748b;
}
.q-text {
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.5;
}
.q-hint {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
padding-left: 40px;
font-weight: 400;
}
.q-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-left: 40px;
}
.q-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #475569;
cursor: pointer;
}
.q-option input[type="checkbox"],
.q-option input[type="radio"] {
accent-color: #1a56db;
}
.answer-area {
margin-top: 12px;
padding-left: 40px;
}
.answer-area textarea {
width: 100%;
min-height: 64px;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
color: #1e293b;
resize: vertical;
background: #fafbfc;
transition: border-color 0.2s;
}
.answer-area textarea:focus {
outline: none;
border-color: #1a56db;
background: #ffffff;
}
.answer-area textarea.large {
min-height: 96px;
}
.answer-line {
margin-top: 12px;
padding-left: 40px;
border-bottom: 1px solid #cbd5e1;
min-height: 28px;
}
/* ── Footer ── */
.footer {
margin-top: 40px;
border-top: 2px solid #e2e8f0;
padding-top: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.footer-left {
font-size: 13px;
color: #64748b;
line-height: 1.8;
}
.footer-left strong {
color: #0f172a;
}
.footer-right {
text-align: right;
font-size: 12px;
color: #94a3b8;
line-height: 1.8;
}
.footer-notice {
margin-top: 20px;
background: #f1f5f9;
border-radius: 8px;
padding: 16px 20px;
font-size: 13px;
color: #64748b;
line-height: 1.7;
}
.footer-notice strong {
color: #475569;
}
/* ── Print Styles ── */
@media print {
body {
background: #ffffff;
}
.page {
padding: 24px 32px;
max-width: none;
box-shadow: none;
}
.question {
border: 1px solid #d1d5db;
break-inside: avoid;
}
.answer-area textarea {
border: none;
border-bottom: 1px solid #999;
border-radius: 0;
background: transparent;
min-height: 48px;
}
.q-option {
border-color: #999;
}
.header {
border-bottom-color: #1a56db;
}
.q-num {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.section-badge {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.footer-notice {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@page {
margin: 16mm 12mm;
}
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<div class="header">
<div class="brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<div>
<div class="brand-name">쟁승메이드</div>
<div class="brand-sub">JaengseungMade Co.</div>
</div>
</div>
<div class="doc-date">문서 작성일: 2026. 04. 02.</div>
</div>
<div style="height: 24px;"></div>
<div class="doc-title">이베이 자동차 부품 AI 리스팅 자동화 — 사전 요구사항 질문지</div>
<div class="doc-subtitle">프로젝트 착수 전, 아래 질문에 답변해 주시면 최적의 솔루션을 설계할 수 있습니다.</div>
<!-- Client Info -->
<div class="client-info">
<div class="field">
<span class="field-label">고객명 <span style="color:#ef4444">*</span></span>
<input type="text" id="clientName" class="field-input" placeholder="홍길동" required>
</div>
<div class="field">
<span class="field-label">연락처</span>
<input type="tel" id="clientPhone" class="field-input" placeholder="010-0000-0000">
</div>
<div class="field">
<span class="field-label">이메일 <span style="color:#ef4444">*</span></span>
<input type="email" id="clientEmail" class="field-input" placeholder="example@email.com" required>
</div>
<div class="field">
<span class="field-label">작성일</span>
<span class="field-value" id="fillDate"></span>
</div>
</div>
<!-- Section 1: Required -->
<div class="section">
<div class="section-header">
<span class="section-badge badge-required">REQUIRED</span>
<span class="section-title">필수 항목 (착수 전 반드시 필요)</span>
</div>
<div class="section-desc">아래 9개 항목은 개발 범위 확정과 견적 산출에 필수적인 정보입니다. 빠짐없이 작성 부탁드립니다.</div>
<!-- Q1 -->
<div class="question">
<div class="q-top">
<span class="q-num">1</span>
<span class="q-text">주로 사용하시는 자동차 부품 사이트 URL을 알려주세요 (최소 3개)</span>
</div>
<div class="q-hint">예: RockAuto, AutoZone, PartsGeek, partsouq.com 등</div>
<div class="answer-area">
<textarea class="large" placeholder="사이트명과 URL을 함께 작성해주세요"></textarea>
</div>
</div>
<!-- Q2 -->
<div class="question">
<div class="q-top">
<span class="q-num">2</span>
<span class="q-text">주요 취급 부품 카테고리는 무엇인가요?</span>
</div>
<div class="q-hint">예: 브레이크, 엔진, 서스펜션, 전기장치, 외장 등</div>
<div class="answer-area">
<textarea placeholder="취급하시는 부품 카테고리를 나열해주세요"></textarea>
</div>
</div>
<!-- Q3 -->
<div class="question">
<div class="q-top">
<span class="q-num">3</span>
<span class="q-text">테스트용 샘플 품번(Part Number) 10~20개를 작성해주세요</span>
</div>
<div class="q-hint">예: 16610-0H040, 04465-33471 등 — 실제 리스팅에 사용하실 품번이면 더 좋습니다</div>
<div class="answer-area">
<textarea class="large" placeholder="품번을 줄바꿈 또는 쉼표로 구분하여 작성해주세요"></textarea>
</div>
</div>
<!-- Q4 -->
<div class="question">
<div class="q-top">
<span class="q-num">4</span>
<span class="q-text">현재 운영 중인 eBay 리스팅 URL을 3~5개 공유해주세요</span>
</div>
<div class="q-hint">현재 리스팅 스타일과 구조를 파악하여 최적화 방향을 설정합니다</div>
<div class="answer-area">
<textarea class="large" placeholder="eBay 리스팅 URL을 줄바꿈으로 구분하여 작성해주세요"></textarea>
</div>
</div>
<!-- Q5 -->
<div class="question">
<div class="q-top">
<span class="q-num">5</span>
<span class="q-text">eBay 셀러 계정 등급은 무엇인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q5"> Basic</label>
<label class="q-option"><input type="radio" name="q5"> Premium</label>
<label class="q-option"><input type="radio" name="q5"> Anchor</label>
<label class="q-option"><input type="radio" name="q5"> Enterprise</label>
<label class="q-option"><input type="radio" name="q5"> 잘 모르겠음</label>
</div>
</div>
<!-- Q6 -->
<div class="question">
<div class="q-top">
<span class="q-num">6</span>
<span class="q-text">주 판매 카테고리를 알려주세요</span>
</div>
<div class="q-hint">eBay Motors &gt; Parts &amp; Accessories 하위 카테고리 기준</div>
<div class="answer-area">
<textarea placeholder="예: Car & Truck Parts > Brakes & Brake Parts"></textarea>
</div>
</div>
<!-- Q7 -->
<div class="question">
<div class="q-top">
<span class="q-num">7</span>
<span class="q-text">예상 월간 리스팅 건수는 몇 건인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q7"> 100건 미만</label>
<label class="q-option"><input type="radio" name="q7"> 100~500건</label>
<label class="q-option"><input type="radio" name="q7"> 500~1,000건</label>
<label class="q-option"><input type="radio" name="q7"> 1,000~5,000건</label>
<label class="q-option"><input type="radio" name="q7"> 5,000건 이상</label>
</div>
</div>
<!-- Q8 -->
<div class="question">
<div class="q-top">
<span class="q-num">8</span>
<span class="q-text">Fitment(호환 차종) 데이터의 정확도 기대치는?</span>
</div>
<div class="q-hint">정확도 요구 수준에 따라 데이터 소스와 검증 로직의 설계가 달라집니다</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q8"> (A) 참고용이면 충분</label>
<label class="q-option"><input type="radio" name="q8"> (B) 정확해야 함 — 틀리면 eBay 계정에 영향</label>
</div>
<div class="answer-area">
<textarea placeholder="추가 의견이 있으시면 작성해주세요 (선택)" style="min-height: 48px;"></textarea>
</div>
</div>
<!-- Q9 -->
<div class="question">
<div class="q-top">
<span class="q-num">9</span>
<span class="q-text">타겟 마켓은 어디인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="checkbox"> US (미국)</label>
<label class="q-option"><input type="checkbox"> UK (영국)</label>
<label class="q-option"><input type="checkbox"> DE (독일)</label>
<label class="q-option"><input type="checkbox"> AU (호주)</label>
<label class="q-option"><input type="checkbox"> 기타</label>
</div>
<div class="answer-area">
<textarea placeholder="기타를 선택하신 경우 국가명을 작성해주세요" style="min-height: 48px;"></textarea>
</div>
</div>
</div>
<!-- Section 2: Optional -->
<div class="section optional">
<div class="section-header">
<span class="section-badge badge-optional">OPTIONAL</span>
<span class="section-title">권장 항목 (있으면 도움)</span>
</div>
<div class="section-desc">아래 항목은 필수는 아니지만, 답변해 주시면 더 정밀한 솔루션 설계에 도움이 됩니다.</div>
<!-- Q10 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">10</span>
<span class="q-text">현재 리스팅 1건 작성에 소요되는 시간은?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q10"> 5분 미만</label>
<label class="q-option"><input type="radio" name="q10"> 5~15분</label>
<label class="q-option"><input type="radio" name="q10"> 15~30분</label>
<label class="q-option"><input type="radio" name="q10"> 30분 이상</label>
</div>
</div>
<!-- Q11 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">11</span>
<span class="q-text">기존 리스팅 관리 방식은?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q11"> 수동 (eBay 웹에서 직접)</label>
<label class="q-option"><input type="radio" name="q11"> CSV 파일 업로드</label>
<label class="q-option"><input type="radio" name="q11"> eBay Seller Hub</label>
<label class="q-option"><input type="radio" name="q11"> 서드파티 툴</label>
</div>
<div class="answer-area">
<textarea placeholder="서드파티 툴을 사용 중이시면 이름을 알려주세요" style="min-height: 48px;"></textarea>
</div>
</div>
<!-- Q12 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">12</span>
<span class="q-text">관세/통관 계산 시 참고하는 사이트나 방식은?</span>
</div>
<div class="answer-area">
<textarea placeholder="사용 중인 관세 계산 방식이나 참고 사이트가 있으면 작성해주세요"></textarea>
</div>
</div>
<!-- Q13 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">13</span>
<span class="q-text">eBay Developer Program API 키를 보유하고 계신가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q13"> 예, 보유하고 있음</label>
<label class="q-option"><input type="radio" name="q13"> 아니오, 없음</label>
<label class="q-option"><input type="radio" name="q13"> 잘 모르겠음</label>
</div>
</div>
<!-- Q14 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">14</span>
<span class="q-text">선호하는 AI 모델이 있나요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q14"> OpenAI (GPT)</label>
<label class="q-option"><input type="radio" name="q14"> Anthropic (Claude)</label>
<label class="q-option"><input type="radio" name="q14"> 상관없음</label>
</div>
</div>
<!-- Q15 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">15</span>
<span class="q-text">현재 사용 중인 자동화 도구가 있나요?</span>
</div>
<div class="answer-area">
<textarea placeholder="사용 중인 도구명과 용도를 작성해주세요 (없으면 '없음')"></textarea>
</div>
</div>
<!-- Q16 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">16</span>
<span class="q-text">OpenAI 또는 Anthropic API 키를 보유하고 계신가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q16"> 예, OpenAI</label>
<label class="q-option"><input type="radio" name="q16"> 예, Anthropic</label>
<label class="q-option"><input type="radio" name="q16"> 둘 다 보유</label>
<label class="q-option"><input type="radio" name="q16"> 없음</label>
</div>
</div>
<!-- Q17 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">17</span>
<span class="q-text">완성된 프로젝트를 쟁승메이드 포트폴리오(사례)로 활용해도 괜찮으신가요?</span>
</div>
<div class="q-hint">고객사명은 비공개 처리 가능합니다</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q17"> 괜찮습니다</label>
<label class="q-option"><input type="radio" name="q17"> 고객사명 비공개 시 가능</label>
<label class="q-option"><input type="radio" name="q17"> 불가합니다</label>
</div>
</div>
</div>
<!-- Additional Notes -->
<div class="section">
<div class="section-header">
<span class="section-title">추가 요청사항</span>
</div>
<div class="question" style="border-color: #cbd5e1;">
<div class="answer-area" style="padding-left: 0;">
<textarea class="large" placeholder="위 항목 외에 추가로 전달하고 싶은 내용이 있으시면 자유롭게 작성해주세요" style="min-height: 120px;"></textarea>
</div>
</div>
</div>
<!-- Submit Section -->
<div class="submit-section">
<button type="button" class="save-draft-btn" onclick="saveDraft()">
임시 저장
</button>
<button type="button" class="submit-btn" id="submitBtn" onclick="submitQuestionnaire()">
질문지 제출하기
</button>
<div id="submitMsg" class="submit-msg"></div>
</div>
<!-- Footer Notice -->
<div class="footer-notice">
<strong>안내사항</strong><br>
본 질문지는 프로젝트 범위 확정 및 정확한 견적 산출을 위한 자료입니다.<br>
작성하신 정보는 프로젝트 진행 목적 외에 사용되지 않으며, 프로젝트 종료 후 안전하게 폐기됩니다.<br>
작성 후 이메일(<strong>bgg8988@gmail.com</strong>)로 회신하시거나, 인쇄 후 스캔본을 보내주셔도 됩니다.
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-left">
<strong>쟁승메이드 (JaengseungMade Co.)</strong><br>
담당: 박재오<br>
이메일: bgg8988@gmail.com<br>
연락처: 010-3907-1392
</div>
<div class="footer-right">
본 문서는 고객 맞춤형 프로젝트<br>
사전 조사를 위해 작성되었습니다.<br>
&copy; 2026 JaengseungMade
</div>
</div>
</div>
<script>
// 작성일 자동 채우기
document.getElementById('fillDate').textContent = new Date().toLocaleDateString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit'
});
// 모든 응답 수집
function collectResponses() {
const responses = {};
// 텍스트 질문 (textarea)
document.querySelectorAll('.question').forEach((q, idx) => {
const num = idx + 1;
const textarea = q.querySelector('textarea');
const radios = q.querySelectorAll('input[type="radio"]');
const checkboxes = q.querySelectorAll('input[type="checkbox"]');
if (radios.length > 0) {
const checked = q.querySelector('input[type="radio"]:checked');
if (checked) {
responses['q' + num] = checked.closest('.q-option').textContent.trim();
}
}
if (checkboxes.length > 0) {
const selected = [];
checkboxes.forEach(cb => {
if (cb.checked) selected.push(cb.closest('.q-option').textContent.trim());
});
if (selected.length > 0) {
responses['q' + num + '_selected'] = selected;
}
}
if (textarea) {
const val = textarea.value.trim();
if (val) {
responses['q' + num + (radios.length > 0 || checkboxes.length > 0 ? '_detail' : '')] = val;
}
}
});
// 추가 요청사항 (마지막 textarea)
const lastSection = document.querySelectorAll('.section');
const additionalTextarea = lastSection[lastSection.length - 1]?.querySelector('textarea');
if (additionalTextarea && additionalTextarea.value.trim()) {
responses['additional'] = additionalTextarea.value.trim();
}
return responses;
}
// 유효성 검사
function validate() {
const name = document.getElementById('clientName').value.trim();
const email = document.getElementById('clientEmail').value.trim();
if (!name) {
alert('고객명을 입력해주세요.');
document.getElementById('clientName').focus();
return false;
}
if (!email) {
alert('이메일을 입력해주세요.');
document.getElementById('clientEmail').focus();
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
alert('올바른 이메일 형식을 입력해주세요.');
document.getElementById('clientEmail').focus();
return false;
}
return true;
}
// 임시 저장 (로컬)
function saveDraft() {
const data = {
clientName: document.getElementById('clientName').value,
clientEmail: document.getElementById('clientEmail').value,
clientPhone: document.getElementById('clientPhone').value,
responses: collectResponses(),
savedAt: new Date().toISOString()
};
localStorage.setItem('questionnaire_draft_ebay', JSON.stringify(data));
const msg = document.getElementById('submitMsg');
msg.className = 'submit-msg success';
msg.textContent = '임시 저장 완료! (브라우저에 저장됨)';
setTimeout(() => { msg.textContent = ''; }, 3000);
}
// 임시 저장 복원
function loadDraft() {
const saved = localStorage.getItem('questionnaire_draft_ebay');
if (!saved) return;
try {
const data = JSON.parse(saved);
if (data.clientName) document.getElementById('clientName').value = data.clientName;
if (data.clientEmail) document.getElementById('clientEmail').value = data.clientEmail;
if (data.clientPhone) document.getElementById('clientPhone').value = data.clientPhone;
} catch (e) {
// 복원 실패 시 무시
}
}
// 제출
async function submitQuestionnaire() {
if (!validate()) return;
const btn = document.getElementById('submitBtn');
const msg = document.getElementById('submitMsg');
btn.disabled = true;
btn.textContent = '제출 중...';
msg.textContent = '';
const payload = {
clientName: document.getElementById('clientName').value.trim(),
clientEmail: document.getElementById('clientEmail').value.trim(),
clientPhone: document.getElementById('clientPhone').value.trim() || null,
responses: collectResponses(),
type: 'ebay-tool'
};
try {
const res = await fetch('/api/questionnaire/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (res.ok && result.success) {
msg.className = 'submit-msg success';
msg.innerHTML = '질문지가 성공적으로 제출되었습니다!<br>담당자가 확인 후 연락드리겠습니다. 감사합니다.';
btn.textContent = '제출 완료';
localStorage.removeItem('questionnaire_draft_ebay');
} else {
throw new Error(result.error || '제출에 실패했습니다.');
}
} catch (err) {
msg.className = 'submit-msg error';
msg.textContent = err.message || '서버 오류가 발생했습니다. 이메일(bgg8988@gmail.com)로 직접 보내주세요.';
btn.disabled = false;
btn.textContent = '질문지 제출하기';
}
}
// 페이지 로드 시 임시 저장 복원
loadDraft();
</script>
</body>
</html>

View File

@@ -76,18 +76,6 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/documents',
label: '프로젝트 문서',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 3v5a2 2 0 002 2h4M9 13h6M9 17h4" />
</svg>
),
},
{
href: '/admin/packs',
label: '팩 자료',
@@ -98,16 +86,6 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/questionnaire',
label: '질문지 응답',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
href: '/admin/marketing',
label: '마케팅 에셋',

View File

@@ -1,160 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface Document {
id: string;
title: string;
description: string;
category: '제안서' | '질문지' | '계약서';
fileName: string;
updatedAt: string;
status: 'draft' | 'sent' | 'accepted';
}
const documents: Document[] = [
{
id: 'ebay-proposal',
title: '이베이 부품 AI 자동화 — 제안서',
description: '프로젝트 개요, 3단 패키지 견적(120/198/330만원), 기술 스택, 진행 절차',
category: '제안서',
fileName: 'ebay-tool-proposal.html',
updatedAt: '2026-04-02',
status: 'draft',
},
{
id: 'ebay-questionnaire',
title: '이베이 부품 AI 자동화 — 요구사항 질문지',
description: '고객 사전 확인 17항목 (타겟 사이트, 샘플 품번, eBay 셀러 티어 등)',
category: '질문지',
fileName: 'ebay-tool-questionnaire.html',
updatedAt: '2026-04-02',
status: 'draft',
},
];
const CATEGORY_COLORS: Record<string, string> = {
'제안서': 'bg-blue-900/40 text-blue-400 border-blue-500/30',
'질문지': 'bg-amber-900/40 text-amber-400 border-amber-500/30',
'계약서': 'bg-green-900/40 text-green-400 border-green-500/30',
};
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
draft: { label: '초안', color: 'bg-slate-700/60 text-slate-300' },
sent: { label: '발송', color: 'bg-blue-900/40 text-blue-400' },
accepted: { label: '수락', color: 'bg-green-900/40 text-green-400' },
};
export default function AdminDocumentsPage() {
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState(false);
// iframe src 대신 fetch + srcdoc 방식으로 X-Frame-Options 우회
useEffect(() => {
if (!previewDoc) { setPreviewHtml(''); return; }
setPreviewLoading(true);
fetch(`/api/admin/documents/${previewDoc.fileName}`)
.then(res => res.ok ? res.text() : Promise.reject('문서를 불러올 수 없습니다'))
.then(html => setPreviewHtml(html))
.catch(() => setPreviewHtml('<p style="padding:2rem;color:red;">문서를 불러올 수 없습니다.</p>'))
.finally(() => setPreviewLoading(false));
}, [previewDoc]);
return (
<div className="p-6 max-w-6xl mx-auto">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
, ,
</p>
</div>
{/* 문서 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{documents.map((doc) => (
<div
key={doc.id}
className="bg-slate-900 rounded-2xl border border-slate-700/50 p-5 flex flex-col"
>
{/* 카테고리 + 상태 뱃지 */}
<div className="flex items-center gap-2 mb-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${CATEGORY_COLORS[doc.category]}`}>
{doc.category}
</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_CONFIG[doc.status].color}`}>
{STATUS_CONFIG[doc.status].label}
</span>
</div>
{/* 제목 + 설명 */}
<h3 className="text-white font-semibold text-sm mb-1.5">{doc.title}</h3>
<p className="text-slate-400 text-xs leading-relaxed mb-4 flex-1">{doc.description}</p>
{/* 수정일 + 버튼 */}
<div className="flex items-center justify-between">
<span className="text-slate-600 text-xs">: {doc.updatedAt}</span>
<div className="flex gap-2">
<button
onClick={() => setPreviewDoc(doc)}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition border border-red-500/20"
>
</button>
<button
onClick={() => window.open(`/api/admin/documents/${doc.fileName}`, '_blank')}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white transition"
>
</button>
</div>
</div>
</div>
))}
</div>
{/* 미리보기 섹션 */}
{previewDoc && (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
{/* 미리보기 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-white text-sm font-medium">{previewDoc.title}</span>
</div>
<button
onClick={() => setPreviewDoc(null)}
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
aria-label="미리보기 닫기"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 문서 미리보기 (fetch + srcdoc 방식) */}
{previewLoading ? (
<div className="flex items-center justify-center bg-white" style={{ height: '80vh' }}>
<div className="text-slate-400 text-sm"> ...</div>
</div>
) : (
<iframe
srcDoc={previewHtml}
className="w-full bg-white"
style={{ height: '80vh' }}
title={previewDoc.title}
sandbox="allow-same-origin"
/>
)}
</div>
)}
</div>
);
}

View File

@@ -1,256 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface QuestionnaireResponse {
id: string;
questionnaire_type: string;
client_name: string;
client_email: string;
client_phone: string | null;
responses: Record<string, unknown>;
status: string;
admin_notes: string | null;
created_at: string;
reviewed_at: string | null;
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
submitted: { label: '접수', color: 'bg-blue-900/40 text-blue-400 border-blue-500/30' },
reviewed: { label: '검토완료', color: 'bg-green-900/40 text-green-400 border-green-500/30' },
archived: { label: '보관', color: 'bg-slate-700/60 text-slate-400 border-slate-500/30' },
};
const QUESTION_LABELS: Record<string, string> = {
q1: '주 사용 부품 사이트 URL',
q2: '주요 취급 부품 카테고리',
q3: '샘플 품번 목록',
q4: '현재 eBay 리스팅 URL',
q5: 'eBay 셀러 계정 등급',
q6: '주 판매 카테고리',
q7: '예상 월간 리스팅 건수',
q8: 'Fitment 정확도 기대치',
q8_detail: 'Fitment 추가 의견',
q9_selected: '타겟 마켓',
q9_detail: '타겟 마켓 기타',
q10: '리스팅 1건 소요 시간',
q11: '기존 리스팅 관리 방식',
q11_detail: '서드파티 툴 이름',
q12: '관세/통관 계산 방식',
q13: 'eBay Developer API 키 보유',
q14: '선호 AI 모델',
q15: '현재 자동화 도구',
q16: 'AI API 키 보유 여부',
q17: '포트폴리오 활용 동의',
additional: '추가 요청사항',
};
export default function AdminQuestionnairePage() {
const [responses, setResponses] = useState<QuestionnaireResponse[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<QuestionnaireResponse | null>(null);
const [adminNotes, setAdminNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchResponses();
}, []);
async function fetchResponses() {
try {
const res = await fetch('/api/admin/questionnaire');
if (!res.ok) throw new Error();
const json = await res.json();
setResponses(json.data || []);
} catch {
setResponses([]);
} finally {
setLoading(false);
}
}
function openDetail(item: QuestionnaireResponse) {
setSelected(item);
setAdminNotes(item.admin_notes || '');
}
async function updateStatus(id: string, status: string) {
setSaving(true);
try {
await fetch(`/api/admin/questionnaire/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, admin_notes: adminNotes }),
});
await fetchResponses();
if (selected?.id === id) {
setSelected(prev => prev ? { ...prev, status, admin_notes: adminNotes } : null);
}
} finally {
setSaving(false);
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
</p>
</div>
{loading ? (
<div className="text-slate-400 text-sm py-12 text-center"> ...</div>
) : responses.length === 0 ? (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 p-12 text-center">
<svg className="w-12 h-12 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p className="text-slate-400 text-sm"> </p>
</div>
) : (
<div className="space-y-4">
{/* 목록 */}
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700/50">
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-right text-slate-400 font-medium px-5 py-3"></th>
</tr>
</thead>
<tbody>
{responses.map((item) => {
const st = STATUS_CONFIG[item.status] || STATUS_CONFIG.submitted;
return (
<tr
key={item.id}
className={`border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer transition ${
selected?.id === item.id ? 'bg-slate-800/50' : ''
}`}
onClick={() => openDetail(item)}
>
<td className="px-5 py-3 text-white font-medium">{item.client_name}</td>
<td className="px-5 py-3 text-slate-300">{item.client_email}</td>
<td className="px-5 py-3 text-slate-400">{item.questionnaire_type}</td>
<td className="px-5 py-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${st.color}`}>
{st.label}
</span>
</td>
<td className="px-5 py-3 text-slate-400">{formatDate(item.created_at)}</td>
<td className="px-5 py-3 text-right">
<svg className="w-4 h-4 text-slate-500 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 상세 패널 */}
{selected && (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50">
<div>
<h3 className="text-white font-semibold">
{selected.client_name}
</h3>
<p className="text-slate-400 text-xs mt-0.5">
{selected.client_email}
{selected.client_phone && ` · ${selected.client_phone}`}
{' · '}: {formatDate(selected.created_at)}
</p>
</div>
<button
onClick={() => setSelected(null)}
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
{Object.entries(selected.responses).map(([key, value]) => (
<div key={key} className="bg-slate-800/50 rounded-lg p-4">
<div className="text-xs text-slate-400 font-medium mb-1.5">
{QUESTION_LABELS[key] || key}
</div>
<div className="text-white text-sm whitespace-pre-wrap">
{Array.isArray(value) ? (value as string[]).join(', ') : String(value)}
</div>
</div>
))}
{Object.keys(selected.responses).length === 0 && (
<p className="text-slate-500 text-sm text-center py-4"> </p>
)}
</div>
{/* 관리자 메모 + 상태 변경 */}
<div className="px-5 py-4 border-t border-slate-700/50 space-y-3">
<div>
<label className="text-slate-400 text-xs font-medium block mb-1.5"> </label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-red-500/50"
rows={2}
placeholder="내부 참고용 메모..."
/>
</div>
<div className="flex gap-2">
{selected.status !== 'reviewed' && (
<button
onClick={() => updateStatus(selected.id, 'reviewed')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 hover:bg-green-600/30 transition border border-green-500/20 disabled:opacity-50"
>
{saving ? '저장 중...' : '검토 완료'}
</button>
)}
{selected.status !== 'archived' && (
<button
onClick={() => updateStatus(selected.id, 'archived')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 transition disabled:opacity-50"
>
</button>
)}
{selected.status !== 'submitted' && (
<button
onClick={() => updateStatus(selected.id, 'submitted')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition border border-blue-500/20 disabled:opacity-50"
>
</button>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import path from 'path';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
const ALLOWED_FILES = [
'ebay-tool-proposal.html',
'ebay-tool-questionnaire.html',
];
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { filename } = await params;
if (!ALLOWED_FILES.includes(filename)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
try {
const filePath = path.join(process.cwd(), 'CONTENT', filename);
const content = await readFile(filePath, 'utf-8');
return new NextResponse(content, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
} catch {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
}

View File

@@ -1,69 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// 질문지 응답 상세 조회
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.select('*')
.eq('id', id)
.single();
if (error) {
console.error('[Admin Questionnaire] DB error:', error);
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
}
return NextResponse.json({ data });
}
// 상태/메모 업데이트
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { status, admin_notes } = body;
const updates: Record<string, unknown> = {};
if (status) updates.status = status;
if (admin_notes !== undefined) updates.admin_notes = admin_notes;
if (status === 'reviewed') updates.reviewed_at = new Date().toISOString();
const admin = createAdminClient();
const { error } = await admin
.from('questionnaire_responses')
.update(updates)
.eq('id', id);
if (error) {
console.error('[Admin Questionnaire] Update error:', error);
return NextResponse.json({ error: '업데이트 실패' }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View File

@@ -1,32 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// 질문지 응답 목록 조회
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('[Admin Questionnaire] DB error:', error);
return NextResponse.json({ error: '데이터 조회 실패' }, { status: 500 });
}
return NextResponse.json({ data });
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
export async function POST(request: Request) {
try {
const body = await request.json();
const { clientName, clientEmail, clientPhone, responses, type } = body;
if (!responses || typeof responses !== 'object') {
return NextResponse.json({ error: '응답 데이터가 없습니다.' }, { status: 400 });
}
if (!clientName || !clientEmail) {
return NextResponse.json({ error: '이름과 이메일은 필수입니다.' }, { status: 400 });
}
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.insert({
questionnaire_type: type || 'ebay-tool',
client_name: clientName,
client_email: clientEmail,
client_phone: clientPhone || null,
responses,
status: 'submitted',
})
.select('id')
.single();
if (error) {
console.error('[Questionnaire] DB insert error:', error);
return NextResponse.json({ error: '저장에 실패했습니다.' }, { status: 500 });
}
return NextResponse.json({ success: true, id: data.id });
} catch (err) {
console.error('[Questionnaire] Submit error:', err);
return NextResponse.json({ error: '서버 오류가 발생했습니다.' }, { status: 500 });
}
}

View File

@@ -1,116 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
import type { CrawlResult, BasicInfo, ListingInfo, FitmentEntry } from './types';
function getClient(): Anthropic {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
return new Anthropic({ apiKey });
}
const SYSTEM_PROMPT = `당신은 자동차 부품 전문가이자 이베이 리스팅 최적화 전문가입니다.
주어진 크롤링 데이터에서 정확한 부품 정보를 추출하고, eBay 리스팅에 최적화된 형태로 정리합니다.
중요 원칙:
- 확인된 정보만 포함합니다. 추측하지 마세요.
- Fitment(호환 차종)은 크롤링 데이터에서 확인된 것만 포함합니다.
- 데이터에 없는 정보는 빈 문자열이나 빈 배열로 남깁니다.
- 이베이 제목은 80자 이내, 핵심 키워드 포함 (브랜드 + 부품명 + 적용차종 + OEM번호)`;
interface AIAnalysisResult {
basicInfo: BasicInfo;
listing: ListingInfo;
fitment: FitmentEntry[];
}
export async function analyzeWithAI(
partNumber: string,
partName: string | undefined,
crawlResults: CrawlResult[]
): Promise<AIAnalysisResult> {
// 크롤링 결과를 텍스트로 정리
const crawlSummary = crawlResults
.map(r => {
if (!r.success) return `[${r.source}] 크롤링 실패: ${r.error}`;
return `[${r.source}] 수집 데이터:\n${JSON.stringify(r.data, null, 2).slice(0, 3000)}`;
})
.join('\n\n---\n\n');
const userMessage = `품번: ${partNumber}
${partName ? `품명: ${partName}` : ''}
아래는 여러 자동차 부품 사이트에서 크롤링한 데이터입니다. 이 데이터를 분석해 이베이 리스팅에 필요한 정보를 정리해주세요.
${crawlSummary}`;
const response = await getClient().messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
tools: [{
name: 'format_listing_data',
description: '이베이 리스팅용 데이터를 구조화된 형태로 반환합니다',
input_schema: {
type: 'object' as const,
properties: {
basicInfo: {
type: 'object' as const,
properties: {
partNumber: { type: 'string' as const },
partName: { type: 'string' as const },
brand: { type: 'string' as const },
oemNumbers: { type: 'array' as const, items: { type: 'string' as const } },
category: { type: 'string' as const },
imageUrl: { type: 'string' as const },
},
required: ['partNumber', 'partName', 'brand', 'oemNumbers', 'category'] as const,
},
listing: {
type: 'object' as const,
properties: {
title: { type: 'string' as const, description: '이베이 리스팅 제목 (80자 이내)' },
category: { type: 'string' as const, description: '이베이 카테고리 ID' },
itemSpecifics: {
type: 'object' as const,
additionalProperties: { type: 'string' as const },
description: '이베이 Item Specifics (Brand, MPN, Type 등)',
},
},
required: ['title', 'category', 'itemSpecifics'] as const,
},
fitment: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
year: { type: 'string' as const },
make: { type: 'string' as const },
model: { type: 'string' as const },
engine: { type: 'string' as const },
confidence: { type: 'string' as const, enum: ['high', 'medium', 'low'] },
source: { type: 'string' as const },
},
required: ['year', 'make', 'model', 'engine', 'confidence', 'source'] as const,
},
description: '호환 차종 목록 (크롤링 데이터에서 확인된 것만)',
},
},
required: ['basicInfo', 'listing', 'fitment'] as const,
},
}],
tool_choice: { type: 'tool', name: 'format_listing_data' },
});
// Tool Use 응답에서 결과 추출
const toolUse = response.content.find(block => block.type === 'tool_use');
if (!toolUse || toolUse.type !== 'tool_use') {
throw new Error('AI 분석 결과를 파싱할 수 없습니다');
}
const input = toolUse.input as Record<string, unknown>;
if (!input.basicInfo || !input.listing || !Array.isArray(input.fitment)) {
throw new Error('AI 응답에 필수 필드가 누락되었습니다');
}
const result = input as unknown as AIAnalysisResult;
return result;
}

View File

@@ -1,133 +0,0 @@
import * as cheerio from 'cheerio';
import type { CrawlResult } from './types';
// 크롤러 설정
const CRAWL_CONFIG = {
rockAuto: {
baseUrl: 'https://www.rockauto.com',
searchUrl: 'https://www.rockauto.com/en/partsearch/',
type: 'http' as const,
rateLimit: 3000, // ms between requests
},
// 향후 사이트 추가
};
// User-Agent 로테이션
const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
];
function getRandomUA() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
// 딜레이 함수
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// HTTP 기반 크롤러
async function fetchPage(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': getRandomUA(),
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}
// RockAuto 검색
export async function crawlRockAuto(partNumber: string): Promise<CrawlResult> {
try {
const searchUrl = `${CRAWL_CONFIG.rockAuto.searchUrl}?partnum=${encodeURIComponent(partNumber)}`;
const html = await fetchPage(searchUrl);
const $ = cheerio.load(html);
// RockAuto 검색 결과 파싱
// MVP: 기본 구조만 추출 (실제 셀렉터는 사이트 구조에 따라 조정 필요)
const results: Record<string, unknown> = {
searchUrl,
title: $('title').text().trim(),
// 부품 정보 추출 시도
parts: [] as Array<Record<string, string>>,
};
// 검색 결과에서 부품 정보 추출 시도
// RockAuto의 실제 DOM 구조에 맞게 셀렉터 조정 필요
const partsList: Array<Record<string, string>> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
$('[id^="vPRD"]').each((_: number, el: any) => {
const $el = $(el);
partsList.push({
name: $el.find('.listing-text-row-moreinfo-truck').text().trim() || $el.text().trim().slice(0, 100),
price: $el.find('.listing-final-price').text().trim(),
brand: $el.find('.listing-text-row-moreinfo-pair .listing-text-row-moreinfo-value').first().text().trim(),
});
});
results.parts = partsList;
// 페이지 전체 텍스트도 보관 (AI 분석용)
results.pageText = $('body').text().replace(/\s+/g, ' ').trim().slice(0, 5000);
return { source: 'RockAuto', success: true, data: results };
} catch (error) {
return {
source: 'RockAuto',
success: false,
data: {},
error: error instanceof Error ? error.message : String(error),
};
}
}
// eBay 검색 (공식 API — MVP에서는 간소화된 Browse API 호출)
export async function searchEbay(partNumber: string): Promise<CrawlResult> {
try {
// eBay Browse API (인증 필요 — MVP에서는 비인증 검색)
// 실제 구현 시 OAuth 토큰 필요
const searchUrl = `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(partNumber)}&_sacat=6028`;
const html = await fetchPage(searchUrl);
const $ = cheerio.load(html);
const listings: Array<Record<string, string>> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
$('.s-item').slice(0, 5).each((_: number, el: any) => {
const $el = $(el);
listings.push({
title: $el.find('.s-item__title').text().trim(),
price: $el.find('.s-item__price').text().trim(),
url: $el.find('.s-item__link').attr('href') || '',
});
});
return {
source: 'eBay',
success: true,
data: { listings, searchUrl, pageText: $('body').text().replace(/\s+/g, ' ').trim().slice(0, 3000) },
};
} catch (error) {
return {
source: 'eBay',
success: false,
data: {},
error: error instanceof Error ? error.message : String(error),
};
}
}
// 메인 크롤링 오케스트레이터
export async function crawlAll(partNumber: string): Promise<CrawlResult[]> {
const results: CrawlResult[] = [];
// 순차 실행 (rate limiting 준수)
results.push(await crawlRockAuto(partNumber));
await delay(CRAWL_CONFIG.rockAuto.rateLimit);
results.push(await searchEbay(partNumber));
return results;
}

View File

@@ -1,75 +0,0 @@
import type { PricingInfo, PriceSource } from './types';
// 환율 조회 (한국은행 공개 API 또는 fallback)
export async function getExchangeRate(): Promise<{ rate: number; source: string; date: string }> {
try {
// ExchangeRate-API (무료 티어)
const res = await fetch('https://open.er-api.com/v6/latest/USD');
if (res.ok) {
const data = await res.json();
return {
rate: data.rates?.KRW || 1380,
source: 'ExchangeRate-API',
date: new Date().toISOString().slice(0, 10),
};
}
} catch {
// fallback
}
return { rate: 1380, source: 'fallback', date: new Date().toISOString().slice(0, 10) };
}
// HS Code 기반 관세율 (자동차 부품 기본)
const CUSTOMS_RATES: Record<string, { hsCode: string; rate: number }> = {
'fuel_pump': { hsCode: '8413.30', rate: 8 },
'brake_pad': { hsCode: '6813.81', rate: 8 },
'filter': { hsCode: '8421.23', rate: 8 },
'sensor': { hsCode: '9032.89', rate: 0 },
'bearing': { hsCode: '8482.10', rate: 8 },
'default': { hsCode: '8708.99', rate: 8 }, // 기타 자동차 부품
};
function estimateCustomsCategory(partName: string): { hsCode: string; rate: number } {
const lower = partName.toLowerCase();
if (lower.includes('pump')) return CUSTOMS_RATES.fuel_pump;
if (lower.includes('brake') || lower.includes('pad')) return CUSTOMS_RATES.brake_pad;
if (lower.includes('filter')) return CUSTOMS_RATES.filter;
if (lower.includes('sensor')) return CUSTOMS_RATES.sensor;
if (lower.includes('bearing')) return CUSTOMS_RATES.bearing;
return CUSTOMS_RATES.default;
}
// 가격 정보 종합
export async function calculatePricing(
sources: PriceSource[],
partName: string
): Promise<PricingInfo> {
const exchangeRate = await getExchangeRate();
const customs = estimateCustomsCategory(partName);
// 최저가 기준 관세 계산
const usdPrices = sources.filter(s => s.currency === 'USD').map(s => s.price);
const lowestUSD = usdPrices.length > 0 ? Math.min(...usdPrices) : 0;
const krwValue = Math.round(lowestUSD * exchangeRate.rate);
// $150 이하 소액면세 (목록통관 기준)
const isExempt = lowestUSD <= 150;
const estimatedDuty = isExempt ? 0 : Math.round(krwValue * (customs.rate / 100));
// VAT = (물품가 + 관세) * 10%
const vat = isExempt ? 0 : Math.round((krwValue + estimatedDuty) * 0.1);
const totalImportCost = krwValue + estimatedDuty + vat;
return {
sources,
exchangeRate,
customs: {
hsCode: customs.hsCode,
dutyRate: `${customs.rate}%`,
estimatedDuty,
vat,
totalImportCost,
isExempt,
disclaimer: '본 관세/부가세 계산은 참고용 추정치이며, 실제 통관 시 세관 심사에 따라 달라질 수 있습니다. 정확한 세액은 관세사에게 문의하세요.',
},
};
}

View File

@@ -1,74 +0,0 @@
export interface SearchRequest {
partNumber: string;
partName?: string;
}
export interface BasicInfo {
partNumber: string;
partName: string;
brand: string;
oemNumbers: string[];
category: string;
imageUrl?: string;
}
export interface ListingInfo {
title: string;
category: string;
itemSpecifics: Record<string, string>;
}
export interface FitmentEntry {
year: string;
make: string;
model: string;
engine: string;
confidence: 'high' | 'medium' | 'low';
source: string;
}
export interface PriceSource {
site: string;
price: number;
currency: string;
url: string;
}
export interface PricingInfo {
sources: PriceSource[];
exchangeRate: { rate: number; source: string; date: string };
customs: {
hsCode: string;
dutyRate: string;
estimatedDuty: number;
vat: number;
totalImportCost: number;
isExempt: boolean;
disclaimer: string;
};
}
export interface SearchResult {
success: boolean;
data: {
basicInfo: BasicInfo;
listing: ListingInfo;
fitment: FitmentEntry[];
pricing: PricingInfo;
rawData: Record<string, unknown>;
meta: {
searchedAt: string;
sourcesChecked: string[];
processingTime: string;
aiModel: string;
};
};
error?: string;
}
export interface CrawlResult {
source: string;
success: boolean;
data: Record<string, unknown>;
error?: string;
}

237
package-lock.json generated
View File

@@ -14,7 +14,6 @@
"@portone/browser-sdk": "^0.1.3",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.99.0",
"cheerio": "^1.2.0",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"framer-motion": "^12.38.0",
@@ -3378,12 +3377,6 @@
"node": "*"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3605,79 +3598,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cheerio/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/cheerio/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3830,34 +3750,6 @@
"node": ">= 8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4192,31 +4084,6 @@
"node": ">=8.10.0"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -8057,18 +7924,6 @@
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8357,55 +8212,6 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@@ -10131,15 +9937,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -10832,40 +10629,6 @@
"node": ">= 8"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -16,7 +16,6 @@
"@portone/browser-sdk": "^0.1.3",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.99.0",
"cheerio": "^1.2.0",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"framer-motion": "^12.38.0",