diff --git a/app/api/tools/ebay-parts/search/route.ts b/app/api/tools/ebay-parts/search/route.ts index 699446c..49a440d 100644 --- a/app/api/tools/ebay-parts/search/route.ts +++ b/app/api/tools/ebay-parts/search/route.ts @@ -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', + 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: crawlResults.map(r => r.source), + processingTime: `${elapsed}s`, + aiModel: hasApiKey ? 'claude-sonnet-4-20250514' : 'fallback (no API key)', }, }, - 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(), - }, - meta: { - searchedAt: new Date().toISOString(), - sourcesChecked: ['RockAuto', 'AutoZone', 'Amazon'], - processingTime: '12.3s', - aiModel: 'claude-sonnet-4-20250514', - }, }; - 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>): 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> +) { + 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: [], + }; +} diff --git a/app/tools/ebay-parts/page.tsx b/app/tools/ebay-parts/page.tsx index 23ff543..770bb56 100644 --- a/app/tools/ebay-parts/page.tsx +++ b/app/tools/ebay-parts/page.tsx @@ -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; - }; - fitment: FitmentEntry[]; - pricing: { - sources: PricingSource[]; - exchangeRate: { rate: number; source: string; date: string }; - customs: { hsCode: string; dutyRate: string; estimatedDuty: number }; - }; - rawData: Record; - 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(null); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [activeTab, setActiveTab] = useState('basic'); const [history, setHistory] = useState([]); const [copied, setCopied] = useState(false); @@ -360,7 +321,12 @@ export default function EbayPartsPage() {

-

관세 참고

+

관세/부가세 참고

+ {pricing.customs.isExempt && ( +

+ $150 이하 소액면세 대상 +

+ )}

HS Code: {pricing.customs.hsCode}

@@ -370,6 +336,13 @@ export default function EbayPartsPage() {

예상 관세: {pricing.customs.estimatedDuty.toLocaleString()}원

+

+ 부가세 (VAT 10%): {pricing.customs.vat.toLocaleString()}원 +

+

+ 총 수입 비용: {pricing.customs.totalImportCost.toLocaleString()}원 +

+

{pricing.customs.disclaimer}

diff --git a/lib/ebay-tools/ai-analyzer.ts b/lib/ebay-tools/ai-analyzer.ts new file mode 100644 index 0000000..71f3c14 --- /dev/null +++ b/lib/ebay-tools/ai-analyzer.ts @@ -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 { + // 크롤링 결과를 텍스트로 정리 + 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; + if (!input.basicInfo || !input.listing || !Array.isArray(input.fitment)) { + throw new Error('AI 응답에 필수 필드가 누락되었습니다'); + } + const result = input as unknown as AIAnalysisResult; + return result; +} diff --git a/lib/ebay-tools/crawler.ts b/lib/ebay-tools/crawler.ts new file mode 100644 index 0000000..20db318 --- /dev/null +++ b/lib/ebay-tools/crawler.ts @@ -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 { + 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 { + try { + const searchUrl = `${CRAWL_CONFIG.rockAuto.searchUrl}?partnum=${encodeURIComponent(partNumber)}`; + const html = await fetchPage(searchUrl); + const $ = cheerio.load(html); + + // RockAuto 검색 결과 파싱 + // MVP: 기본 구조만 추출 (실제 셀렉터는 사이트 구조에 따라 조정 필요) + const results: Record = { + searchUrl, + title: $('title').text().trim(), + // 부품 정보 추출 시도 + parts: [] as Array>, + }; + + // 검색 결과에서 부품 정보 추출 시도 + // RockAuto의 실제 DOM 구조에 맞게 셀렉터 조정 필요 + const partsList: Array> = []; + // 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 { + 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> = []; + // 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 { + const results: CrawlResult[] = []; + + // 순차 실행 (rate limiting 준수) + results.push(await crawlRockAuto(partNumber)); + await delay(CRAWL_CONFIG.rockAuto.rateLimit); + results.push(await searchEbay(partNumber)); + + return results; +} diff --git a/lib/ebay-tools/pricing.ts b/lib/ebay-tools/pricing.ts new file mode 100644 index 0000000..97d66df --- /dev/null +++ b/lib/ebay-tools/pricing.ts @@ -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 = { + '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 { + 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: '본 관세/부가세 계산은 참고용 추정치이며, 실제 통관 시 세관 심사에 따라 달라질 수 있습니다. 정확한 세액은 관세사에게 문의하세요.', + }, + }; +} diff --git a/lib/ebay-tools/types.ts b/lib/ebay-tools/types.ts new file mode 100644 index 0000000..c514f15 --- /dev/null +++ b/lib/ebay-tools/types.ts @@ -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; +} + +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; + meta: { + searchedAt: string; + sourcesChecked: string[]; + processingTime: string; + aiModel: string; + }; + }; + error?: string; +} + +export interface CrawlResult { + source: string; + success: boolean; + data: Record; + error?: string; +} diff --git a/package-lock.json b/package-lock.json index d069520..e768167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 5c597ae..1867a1d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/supabase/migrations/004_ebay_search_history.sql b/supabase/migrations/004_ebay_search_history.sql new file mode 100644 index 0000000..f2f8b5e --- /dev/null +++ b/supabase/migrations/004_ebay_search_history.sql @@ -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 역할에 전체 권한을 부여하지 않습니다