diff --git a/app/components/used-sales/MarketPriceAnalysis.vue b/app/components/used-sales/MarketPriceAnalysis.vue index 79851b1..f6d01a0 100644 --- a/app/components/used-sales/MarketPriceAnalysis.vue +++ b/app/components/used-sales/MarketPriceAnalysis.vue @@ -2,6 +2,21 @@ import { PLATFORM_LABELS } from '~/types/used-sale' import type { UsedSale } from '~/types/used-sale' +interface ShoppingItem { + title: string + link: string + image: string + lprice: string + hprice: string + mallName: string + productId: string +} + +interface ShoppingResult { + total: number + items: ShoppingItem[] +} + const props = defineProps<{ open: boolean sale: UsedSale | null @@ -11,64 +26,79 @@ const emit = defineEmits<{ 'update:open': [value: boolean] }>() -const streamingContent = ref('') -const isStreaming = ref(false) +const result = ref(null) +const loading = ref(false) const error = ref(null) -async function startAnalysis() { +async function fetchPrices() { if (!props.sale) return - streamingContent.value = '' - isStreaming.value = true + result.value = null + loading.value = true error.value = null try { - const response = await fetch('/api/ai/market-price', { + result.value = await $fetch('/api/ai/market-price', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: { itemName: props.sale.item_name, - platform: PLATFORM_LABELS[props.sale.platform], salePrice: props.sale.sale_price - }) + } }) - - if (!response.ok) { - throw new Error(`분석 요청 실패 (${response.status})`) - } - - const reader = response.body!.getReader() - const decoder = new TextDecoder() - - while (true) { - const { done, value } = await reader.read() - if (done) break - streamingContent.value += decoder.decode(value, { stream: true }) - } } catch (e: unknown) { - error.value = e instanceof Error ? e.message : '분석 중 오류가 발생했습니다' + const msg = e instanceof Error ? e.message : '조회 중 오류가 발생했습니다' + error.value = msg } finally { - isStreaming.value = false + loading.value = false } } -// 모달이 열릴 때 자동으로 분석 시작 +// 모달이 열릴 때 자동 조회 watch(() => props.open, (isOpen) => { - if (isOpen && props.sale) { - startAnalysis() - } + if (isOpen && props.sale) fetchPrices() }) -function formatPrice(price: number) { - return price.toLocaleString('ko-KR') + '원' +// HTML 태그 제거 (네이버 응답에 태그 포함) +function stripHtml(html: string) { + return html.replace(/<[^>]*>/g, '') } + +function formatPrice(price: string | number) { + const n = typeof price === 'string' ? parseInt(price, 10) : price + return isNaN(n) ? '-' : n.toLocaleString('ko-KR') + '원' +} + +// 신품 최저가 (lprice 기준) +const lowestPrice = computed(() => { + const prices = (result.value?.items ?? []) + .map(i => parseInt(i.lprice, 10)) + .filter(p => p > 0) + return prices.length ? Math.min(...prices) : null +}) + +// 신품 평균가 +const avgPrice = computed(() => { + const prices = (result.value?.items ?? []) + .map(i => parseInt(i.lprice, 10)) + .filter(p => p > 0) + if (!prices.length) return null + return Math.round(prices.reduce((a, b) => a + b, 0) / prices.length) +}) + +// 희망 판매가와 신품 최저가 비교 (양수 = 신품보다 비쌈) +const priceComparison = computed(() => { + if (!lowestPrice.value || !props.sale) return null + const diff = props.sale.sale_price - lowestPrice.value + const ratio = (diff / lowestPrice.value) * 100 + return { diff, ratio } +}) @@ -131,9 +212,9 @@ function formatPrice(price: number) { icon="i-lucide-refresh-cw" color="neutral" variant="outline" - label="재분석" - :disabled="isStreaming" - @click="startAnalysis" + label="다시 조회" + :disabled="loading" + @click="fetchPrices" /> { const config = useRuntimeConfig() const body = await readBody(event) - const { itemName, platform, salePrice } = body as { - itemName: string - platform: string - salePrice: number + const { itemName } = body as { itemName: string; salePrice: number } + + if (!itemName?.trim()) { + throw createError({ statusCode: 400, message: '장비명이 필요합니다' }) } - const anthropic = new Anthropic({ apiKey: config.anthropicApiKey }) + const clientId = config.naverClientId as string + const clientSecret = config.naverClientSecret as string - const userMessage = `다음 캠핑 장비의 중고 시세를 분석해주세요. + if (!clientId || !clientSecret) { + throw createError({ statusCode: 500, message: '네이버 API 인증 정보가 설정되지 않았습니다' }) + } -- 장비명: ${itemName} -- 판매 플랫폼: ${platform} -- 희망 판매가: ${salePrice.toLocaleString('ko-KR')}원` + const url = new URL('https://openapi.naver.com/v1/search/shop.json') + url.searchParams.set('query', itemName) + url.searchParams.set('display', '10') + url.searchParams.set('sort', 'asc') // 가격 오름차순 (최저가 먼저) + url.searchParams.set('exclude', 'used:rental') // 중고·렌탈 제외 → 신품 기준 - const stream = anthropic.messages.stream({ - model: 'claude-sonnet-4-6', - max_tokens: 1024, - system: SYSTEM_PROMPT, - messages: [{ role: 'user', content: userMessage }] - }) - - const readableStream = new ReadableStream({ - async start(controller) { - stream.on('text', (text) => { - controller.enqueue(new TextEncoder().encode(text)) - }) - stream.on('finalMessage', () => { - controller.close() - }) - stream.on('error', (err) => { - controller.error(err) - }) + const response = await fetch(url.toString(), { + headers: { + 'X-Naver-Client-Id': clientId, + 'X-Naver-Client-Secret': clientSecret } }) - return sendStream(event, readableStream) + if (!response.ok) { + const text = await response.text() + throw createError({ statusCode: response.status, message: `네이버 쇼핑 API 오류: ${text}` }) + } + + const data = await response.json() as NaverShoppingResponse + return data })