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

@@ -0,0 +1,116 @@
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;
}

133
lib/ebay-tools/crawler.ts Normal file
View File

@@ -0,0 +1,133 @@
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;
}

75
lib/ebay-tools/pricing.ts Normal file
View File

@@ -0,0 +1,75 @@
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: '본 관세/부가세 계산은 참고용 추정치이며, 실제 통관 시 세관 심사에 따라 달라질 수 있습니다. 정확한 세액은 관세사에게 문의하세요.',
},
};
}

74
lib/ebay-tools/types.ts Normal file
View File

@@ -0,0 +1,74 @@
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;
}