feat: 이베이 부품 AI 리스팅 툴 — 실제 크롤링·AI·가격 모듈 구현

[핵심 모듈 (lib/ebay-tools/)]
- types.ts: 검색 요청/결과/크롤링/가격 공통 타입 정의
- crawler.ts: RockAuto HTTP 크롤러 + eBay 검색 (cheerio, UA 로테이션)
- ai-analyzer.ts: Claude API Tool Use로 크롤링 결과 구조화 (lazy 클라이언트, 런타임 검증)
- pricing.ts: 환율 API 연동 + HS Code 관세 + VAT + 소액면세 계산

[검색 API]
- Mock 데이터 → 실제 크롤링+AI+가격 파이프라인으로 교체
- AI 실패 시 fallback 결과 생성
- 입력값 50자 제한 + 허용 문자 검증

[프론트엔드]
- 중복 타입 제거 → lib/ebay-tools/types import
- 가격 탭에 VAT, 총 수입비용, 면세 여부, 면책 문구 추가

[DB]
- 004_ebay_search_history.sql: 검색 이력 테이블 + RLS (anon 전체 권한 제거)

[Evaluator 반영]
- anon RLS 보안 취약점 수정
- AI 응답 런타임 필드 검증 추가
- Anthropic 클라이언트 lazy 초기화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 14:04:22 +09:00
parent 244781f96a
commit 7003e8d27e
9 changed files with 831 additions and 105 deletions

View File

@@ -1,50 +1,11 @@
'use client';
import { useState, useCallback } from 'react';
import type { SearchResult as FullSearchResult, FitmentEntry, PriceSource } from '@/lib/ebay-tools/types';
/* ── Types ──────────────────────────────────────────────────── */
interface FitmentEntry {
year: string;
make: string;
model: string;
engine: string;
confidence: 'high' | 'medium' | 'low';
}
interface PricingSource {
site: string;
price: number;
currency: string;
url: string;
}
interface SearchResult {
basicInfo: {
partNumber: string;
partName: string;
brand: string;
oemNumbers: string[];
category: string;
};
listing: {
title: string;
category: string;
itemSpecifics: Record<string, string>;
};
fitment: FitmentEntry[];
pricing: {
sources: PricingSource[];
exchangeRate: { rate: number; source: string; date: string };
customs: { hsCode: string; dutyRate: string; estimatedDuty: number };
};
rawData: Record<string, unknown>;
meta: {
searchedAt: string;
sourcesChecked: string[];
processingTime: string;
aiModel: string;
};
}
/* ── Types (페이지 전용) ──────────────────────────────────── */
// SearchResult['data']에 해당하는 타입 (API 응답의 data 필드)
type PageSearchResult = FullSearchResult['data'];
interface HistoryItem {
partNumber: string;
@@ -96,7 +57,7 @@ export default function EbayPartsPage() {
const [partName, setPartName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SearchResult | null>(null);
const [result, setResult] = useState<PageSearchResult | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('basic');
const [history, setHistory] = useState<HistoryItem[]>([]);
const [copied, setCopied] = useState(false);
@@ -360,7 +321,12 @@ export default function EbayPartsPage() {
</p>
</div>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<p className="text-xs text-slate-500 font-medium mb-2"> </p>
<p className="text-xs text-slate-500 font-medium mb-2">/ </p>
{pricing.customs.isExempt && (
<p className="text-sm text-emerald-700 font-semibold mb-2">
$150
</p>
)}
<p className="text-sm text-slate-900">
HS Code: <span className="font-mono font-bold">{pricing.customs.hsCode}</span>
</p>
@@ -370,6 +336,13 @@ export default function EbayPartsPage() {
<p className="text-sm text-slate-900">
: <span className="font-bold text-blue-700">{pricing.customs.estimatedDuty.toLocaleString()}</span>
</p>
<p className="text-sm text-slate-900">
(VAT 10%): <span className="font-bold text-blue-700">{pricing.customs.vat.toLocaleString()}</span>
</p>
<p className="text-sm text-slate-900 mt-1">
: <span className="font-bold text-blue-800 text-base">{pricing.customs.totalImportCost.toLocaleString()}</span>
</p>
<p className="text-xs text-slate-400 mt-2">{pricing.customs.disclaimer}</p>
</div>
</div>
</div>