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:
@@ -1,6 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { crawlAll } from '@/lib/ebay-tools/crawler';
|
||||
import { analyzeWithAI } from '@/lib/ebay-tools/ai-analyzer';
|
||||
import { calculatePricing } from '@/lib/ebay-tools/pricing';
|
||||
import type { SearchResult, PriceSource } from '@/lib/ebay-tools/types';
|
||||
|
||||
export const maxDuration = 60; // Vercel Pro timeout
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { partNumber, partName } = body;
|
||||
@@ -12,69 +20,70 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// MVP: 1.5초 딜레이로 실제 크롤링 소요 시간 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const trimmedPart = partNumber.trim();
|
||||
const trimmedName = partName?.trim() || 'Fuel Pump Assembly';
|
||||
|
||||
const mockData = {
|
||||
basicInfo: {
|
||||
partNumber: trimmedPart,
|
||||
partName: trimmedName,
|
||||
brand: 'Toyota / Denso',
|
||||
oemNumbers: [trimmedPart, '23220-0H040'],
|
||||
category:
|
||||
'eBay Motors > Parts & Accessories > Car & Truck Parts > Fuel System > Fuel Pumps',
|
||||
},
|
||||
listing: {
|
||||
title: `${trimmedName} For Toyota Camry 2007-2011 2.4L ${trimmedPart} OEM Denso`,
|
||||
category: '33549',
|
||||
itemSpecifics: {
|
||||
Brand: 'Denso',
|
||||
'Manufacturer Part Number': trimmedPart,
|
||||
Type: trimmedName,
|
||||
'Placement on Vehicle': 'In-Tank',
|
||||
Voltage: '12V',
|
||||
Warranty: '1 Year',
|
||||
},
|
||||
},
|
||||
fitment: [
|
||||
{ year: '2007', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
|
||||
{ year: '2008', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
|
||||
{ year: '2009', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
|
||||
{ year: '2010', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
|
||||
{ year: '2011', make: 'Toyota', model: 'Camry', engine: '2.4L L4', confidence: 'high' },
|
||||
{ year: '2007', make: 'Toyota', model: 'Camry', engine: '3.5L V6', confidence: 'medium' },
|
||||
],
|
||||
pricing: {
|
||||
sources: [
|
||||
{ site: 'RockAuto', price: 89.99, currency: 'USD', url: 'https://www.rockauto.com/en/catalog/toyota,2009,camry,2.4l+l4,1443745,fuel+&+air,fuel+pump+&+housing+assembly,6256' },
|
||||
{ site: 'AutoZone', price: 129.99, currency: 'USD', url: 'https://www.autozone.com/fuel-delivery/fuel-pump-assembly' },
|
||||
{ site: 'Amazon', price: 95.5, currency: 'USD', url: 'https://www.amazon.com/dp/B07EXAMPLE' },
|
||||
],
|
||||
exchangeRate: { rate: 1380, source: '한국은행', date: '2026-04-02' },
|
||||
customs: { hsCode: '8413.30', dutyRate: '8%', estimatedDuty: 9920 },
|
||||
},
|
||||
rawData: {
|
||||
crawledSources: ['RockAuto', 'AutoZone', 'Amazon'],
|
||||
rawResults: {
|
||||
rockauto: { found: true, listings: 3, avgPrice: 89.99 },
|
||||
autozone: { found: true, listings: 1, avgPrice: 129.99 },
|
||||
amazon: { found: true, listings: 5, avgPrice: 95.5 },
|
||||
},
|
||||
fitmentSources: ['PartsFinder DB', 'eBay Catalog'],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
if (trimmedPart.length > 50) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '품번은 50자 이내로 입력해주세요.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9\s\-_.\/]+$/.test(trimmedPart)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '품번에 허용되지 않는 문자가 포함되어 있습니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedName = partName?.trim() || undefined;
|
||||
|
||||
// 1. 크롤링 (RockAuto + eBay)
|
||||
const crawlResults = await crawlAll(trimmedPart);
|
||||
|
||||
// 2. AI 분석 (Claude API)
|
||||
let aiResult;
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (hasApiKey) {
|
||||
try {
|
||||
aiResult = await analyzeWithAI(trimmedPart, trimmedName, crawlResults);
|
||||
} catch (aiError) {
|
||||
console.error('[EbayParts] AI analysis failed, using fallback:', aiError);
|
||||
}
|
||||
}
|
||||
|
||||
// AI 실패 또는 API 키 없으면 크롤링 데이터에서 기본 추출
|
||||
if (!aiResult) {
|
||||
aiResult = buildFallbackResult(trimmedPart, trimmedName, crawlResults);
|
||||
}
|
||||
|
||||
// 3. 가격 비교 + 환율/관세 계산
|
||||
const priceSources: PriceSource[] = extractPrices(crawlResults);
|
||||
const pricing = await calculatePricing(priceSources, aiResult.basicInfo.partName);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
const result: SearchResult = {
|
||||
success: true,
|
||||
data: {
|
||||
basicInfo: aiResult.basicInfo,
|
||||
listing: aiResult.listing,
|
||||
fitment: aiResult.fitment,
|
||||
pricing,
|
||||
rawData: Object.fromEntries(
|
||||
crawlResults.map(r => [r.source, { success: r.success, data: r.data, error: r.error }])
|
||||
),
|
||||
meta: {
|
||||
searchedAt: new Date().toISOString(),
|
||||
sourcesChecked: ['RockAuto', 'AutoZone', 'Amazon'],
|
||||
processingTime: '12.3s',
|
||||
aiModel: 'claude-sonnet-4-20250514',
|
||||
sourcesChecked: crawlResults.map(r => r.source),
|
||||
processingTime: `${elapsed}s`,
|
||||
aiModel: hasApiKey ? 'claude-sonnet-4-20250514' : 'fallback (no API key)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json({ success: true, data: mockData }, { status: 200 });
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('[EbayParts] Search error:', error);
|
||||
return NextResponse.json(
|
||||
@@ -83,3 +92,76 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 크롤링 결과에서 가격 추출
|
||||
function extractPrices(crawlResults: Awaited<ReturnType<typeof crawlAll>>): PriceSource[] {
|
||||
const prices: PriceSource[] = [];
|
||||
|
||||
for (const result of crawlResults) {
|
||||
if (!result.success) continue;
|
||||
|
||||
if (result.source === 'RockAuto') {
|
||||
const parts = (result.data.parts as Array<{ price?: string; name?: string }>) || [];
|
||||
for (const part of parts) {
|
||||
if (part.price) {
|
||||
const numericPrice = parseFloat(part.price.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(numericPrice) && numericPrice > 0) {
|
||||
prices.push({
|
||||
site: 'RockAuto',
|
||||
price: numericPrice,
|
||||
currency: 'USD',
|
||||
url: String(result.data.searchUrl || ''),
|
||||
});
|
||||
break; // 첫 번째 가격만
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.source === 'eBay') {
|
||||
const listings = (result.data.listings as Array<{ price?: string; url?: string }>) || [];
|
||||
for (const listing of listings.slice(0, 2)) {
|
||||
if (listing.price) {
|
||||
const numericPrice = parseFloat(listing.price.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(numericPrice) && numericPrice > 0) {
|
||||
prices.push({
|
||||
site: 'eBay (참고)',
|
||||
price: numericPrice,
|
||||
currency: 'USD',
|
||||
url: listing.url || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
// AI 없이 기본 결과 생성
|
||||
function buildFallbackResult(
|
||||
partNumber: string,
|
||||
partName: string | undefined,
|
||||
crawlResults: Awaited<ReturnType<typeof crawlAll>>
|
||||
) {
|
||||
const name = partName || partNumber;
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
partNumber,
|
||||
partName: name,
|
||||
brand: '',
|
||||
oemNumbers: [partNumber],
|
||||
category: 'eBay Motors > Parts & Accessories > Car & Truck Parts',
|
||||
},
|
||||
listing: {
|
||||
title: `${name} ${partNumber} Auto Part`,
|
||||
category: '',
|
||||
itemSpecifics: {
|
||||
'Manufacturer Part Number': partNumber,
|
||||
},
|
||||
},
|
||||
fitment: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
116
lib/ebay-tools/ai-analyzer.ts
Normal file
116
lib/ebay-tools/ai-analyzer.ts
Normal 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
133
lib/ebay-tools/crawler.ts
Normal 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
75
lib/ebay-tools/pricing.ts
Normal 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
74
lib/ebay-tools/types.ts
Normal 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;
|
||||
}
|
||||
241
package-lock.json
generated
241
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.99.0",
|
||||
"@tosspayments/tosspayments-sdk": "^2.6.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"lunar-javascript": "^1.7.7",
|
||||
"next": "16.1.6",
|
||||
@@ -1924,7 +1925,6 @@
|
||||
"version": "19.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2870,6 +2870,12 @@
|
||||
"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",
|
||||
@@ -3081,6 +3087,79 @@
|
||||
"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",
|
||||
@@ -3224,11 +3303,38 @@
|
||||
"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",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -3559,6 +3665,31 @@
|
||||
"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",
|
||||
@@ -7301,6 +7432,18 @@
|
||||
"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",
|
||||
@@ -7575,6 +7718,55 @@
|
||||
"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",
|
||||
@@ -9189,6 +9381,15 @@
|
||||
"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",
|
||||
@@ -9414,6 +9615,40 @@
|
||||
"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",
|
||||
@@ -9744,7 +9979,7 @@
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.99.0",
|
||||
"@tosspayments/tosspayments-sdk": "^2.6.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"lunar-javascript": "^1.7.7",
|
||||
"next": "16.1.6",
|
||||
|
||||
37
supabase/migrations/004_ebay_search_history.sql
Normal file
37
supabase/migrations/004_ebay_search_history.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- ============================================================
|
||||
-- 이베이 부품 검색 이력 테이블
|
||||
-- 목적: 검색 결과 캐싱 + 사용자별 검색 이력 관리
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ebay_search_history (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
part_number VARCHAR(100) NOT NULL,
|
||||
part_name VARCHAR(500),
|
||||
result JSONB NOT NULL,
|
||||
sources_checked TEXT[] DEFAULT '{}',
|
||||
processing_time VARCHAR(20),
|
||||
ai_model VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_ebay_search_part_number ON ebay_search_history(part_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ebay_search_user ON ebay_search_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ebay_search_created ON ebay_search_history(created_at DESC);
|
||||
|
||||
-- RLS (로그인 사용자는 본인 이력만 조회)
|
||||
ALTER TABLE ebay_search_history ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users view own search history"
|
||||
ON ebay_search_history FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users insert own search history"
|
||||
ON ebay_search_history FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 서버 사이드 관리자 작업은 service_role 키를 사용하여 RLS를 우회합니다
|
||||
-- anon 역할에 전체 권한을 부여하지 않습니다
|
||||
Reference in New Issue
Block a user