♻️ refactor: AI 시세 분석을 네이버 쇼핑 최저가 조회로 교체
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 24m12s
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 24m12s
- server/api/ai/market-price: Anthropic 스트리밍 제거, 네이버 쇼핑 API 호출로 변경 - 신품 기준 최저가 10건 조회 (가격 오름차순, 중고·렌탈 제외) - NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 환경변수 사용 - MarketPriceAnalysis: 스트리밍 텍스트 UI → 구조화된 가격 비교 UI - 신품 최저가 / 평균가 / 희망가 대비 비율 요약 카드 - 상품 목록 (이미지, 상품명, 쇼핑몰, 가격, 네이버 링크) - nuxt.config: naverClientId / naverClientSecret runtimeConfig 추가
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
import { PLATFORM_LABELS } from '~/types/used-sale'
|
import { PLATFORM_LABELS } from '~/types/used-sale'
|
||||||
import type { UsedSale } 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<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
sale: UsedSale | null
|
sale: UsedSale | null
|
||||||
@@ -11,64 +26,79 @@ const emit = defineEmits<{
|
|||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const streamingContent = ref('')
|
const result = ref<ShoppingResult | null>(null)
|
||||||
const isStreaming = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
async function startAnalysis() {
|
async function fetchPrices() {
|
||||||
if (!props.sale) return
|
if (!props.sale) return
|
||||||
|
|
||||||
streamingContent.value = ''
|
result.value = null
|
||||||
isStreaming.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/ai/market-price', {
|
result.value = await $fetch<ShoppingResult>('/api/ai/market-price', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: {
|
||||||
body: JSON.stringify({
|
|
||||||
itemName: props.sale.item_name,
|
itemName: props.sale.item_name,
|
||||||
platform: PLATFORM_LABELS[props.sale.platform],
|
|
||||||
salePrice: props.sale.sale_price
|
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) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : '분석 중 오류가 발생했습니다'
|
const msg = e instanceof Error ? e.message : '조회 중 오류가 발생했습니다'
|
||||||
|
error.value = msg
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모달이 열릴 때 자동으로 분석 시작
|
// 모달이 열릴 때 자동 조회
|
||||||
watch(() => props.open, (isOpen) => {
|
watch(() => props.open, (isOpen) => {
|
||||||
if (isOpen && props.sale) {
|
if (isOpen && props.sale) fetchPrices()
|
||||||
startAnalysis()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatPrice(price: number) {
|
// HTML 태그 제거 (네이버 응답에 <b> 태그 포함)
|
||||||
return price.toLocaleString('ko-KR') + '원'
|
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 }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal
|
<UModal
|
||||||
:open="open"
|
:open="open"
|
||||||
title="AI 시세 분석"
|
title="네이버 쇼핑 최저가 조회"
|
||||||
description="AI가 현재 중고 시장 시세를 분석합니다"
|
description="신품 기준 최저가와 희망 판매가를 비교합니다"
|
||||||
:ui="{ body: 'space-y-4' }"
|
:ui="{ body: 'space-y-4' }"
|
||||||
@update:open="emit('update:open', $event)"
|
@update:open="emit('update:open', $event)"
|
||||||
>
|
>
|
||||||
@@ -85,25 +115,18 @@ function formatPrice(price: number) {
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge color="primary" variant="subtle" size="lg">
|
<UBadge color="primary" variant="subtle" size="lg">
|
||||||
<UIcon name="i-lucide-tag" class="mr-1" />
|
<UIcon name="i-lucide-tag" class="mr-1" />
|
||||||
{{ formatPrice(sale.sale_price) }}
|
희망가 {{ sale ? formatPrice(sale.sale_price) : '-' }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 분석 결과 영역 -->
|
|
||||||
<div class="min-h-40 rounded-lg bg-gray-50 dark:bg-gray-900 p-4">
|
|
||||||
<!-- 로딩 스켈레톤 -->
|
<!-- 로딩 스켈레톤 -->
|
||||||
<div v-if="isStreaming && !streamingContent" class="space-y-3">
|
<div v-if="loading" class="space-y-3">
|
||||||
<USkeleton class="h-4 w-3/4" />
|
<USkeleton class="h-20 w-full rounded-lg" />
|
||||||
<USkeleton class="h-4 w-full" />
|
<USkeleton class="h-14 w-full rounded-lg" />
|
||||||
<USkeleton class="h-4 w-5/6" />
|
<USkeleton class="h-14 w-full rounded-lg" />
|
||||||
|
<USkeleton class="h-14 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 스트리밍 텍스트 -->
|
|
||||||
<pre
|
|
||||||
v-else-if="streamingContent"
|
|
||||||
class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-200 font-sans leading-relaxed"
|
|
||||||
>{{ streamingContent }}</pre>
|
|
||||||
|
|
||||||
<!-- 에러 -->
|
<!-- 에러 -->
|
||||||
<UAlert
|
<UAlert
|
||||||
v-else-if="error"
|
v-else-if="error"
|
||||||
@@ -112,16 +135,74 @@ function formatPrice(price: number) {
|
|||||||
:description="error"
|
:description="error"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 대기 상태 (아직 아무것도 없을 때) -->
|
<!-- 결과 -->
|
||||||
<div v-else class="flex items-center justify-center h-32 text-gray-400 dark:text-gray-600">
|
<template v-else-if="result">
|
||||||
<UIcon name="i-lucide-sparkles" class="text-2xl" />
|
<!-- 가격 요약 카드 -->
|
||||||
</div>
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<UCard :ui="{ body: 'p-3 text-center' }">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">신품 최저가</p>
|
||||||
|
<p class="text-lg font-bold text-green-600 dark:text-green-400">
|
||||||
|
{{ lowestPrice ? formatPrice(lowestPrice) : '-' }}
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
<UCard :ui="{ body: 'p-3 text-center' }">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">신품 평균가</p>
|
||||||
|
<p class="text-lg font-bold">
|
||||||
|
{{ avgPrice ? formatPrice(avgPrice) : '-' }}
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
<UCard :ui="{ body: 'p-3 text-center' }">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">희망가 비교</p>
|
||||||
|
<template v-if="priceComparison">
|
||||||
|
<p
|
||||||
|
class="text-lg font-bold"
|
||||||
|
:class="priceComparison.ratio > 0 ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'"
|
||||||
|
>
|
||||||
|
{{ priceComparison.ratio > 0 ? '+' : '' }}{{ priceComparison.ratio.toFixed(0) }}%
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400">신품 최저가 대비</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-lg font-bold text-gray-400">-</p>
|
||||||
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 스트리밍 중 인디케이터 -->
|
<!-- 상품 목록 -->
|
||||||
<div v-if="isStreaming && streamingContent" class="flex items-center gap-2 text-sm text-gray-500">
|
<div class="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||||
<span class="inline-block w-2 h-2 bg-primary-500 rounded-full animate-pulse" />
|
<a
|
||||||
분석 중...
|
v-for="item in result.items"
|
||||||
|
:key="item.productId"
|
||||||
|
:href="item.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.image"
|
||||||
|
:src="item.image"
|
||||||
|
:alt="stripHtml(item.title)"
|
||||||
|
class="w-12 h-12 object-contain rounded bg-white flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">{{ stripHtml(item.title) }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ item.mallName }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0 flex items-center gap-1">
|
||||||
|
<p class="text-sm font-bold text-green-600 dark:text-green-400">
|
||||||
|
{{ formatPrice(item.lprice) }}
|
||||||
|
</p>
|
||||||
|
<UIcon name="i-lucide-external-link" class="text-gray-400 size-3" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 text-center">
|
||||||
|
네이버 쇼핑 검색 결과 {{ result.total.toLocaleString() }}건 중 최저가 {{ result.items.length }}건
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 초기 대기 상태 -->
|
||||||
|
<div v-else class="flex items-center justify-center h-32 text-gray-400 dark:text-gray-600">
|
||||||
|
<UIcon name="i-lucide-search" class="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -131,9 +212,9 @@ function formatPrice(price: number) {
|
|||||||
icon="i-lucide-refresh-cw"
|
icon="i-lucide-refresh-cw"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
label="재분석"
|
label="다시 조회"
|
||||||
:disabled="isStreaming"
|
:disabled="loading"
|
||||||
@click="startAnalysis"
|
@click="fetchPrices"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
label="닫기"
|
label="닫기"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
naverClientId: process.env.NAVER_CLIENT_ID,
|
||||||
|
naverClientSecret: process.env.NAVER_CLIENT_SECRET,
|
||||||
public: {}
|
public: {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,61 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
interface NaverShoppingItem {
|
||||||
|
title: string
|
||||||
|
link: string
|
||||||
|
image: string
|
||||||
|
lprice: string
|
||||||
|
hprice: string
|
||||||
|
mallName: string
|
||||||
|
productId: string
|
||||||
|
brand: string
|
||||||
|
category1: string
|
||||||
|
category2: string
|
||||||
|
category3: string
|
||||||
|
category4: string
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `당신은 한국 중고 캠핑 장비 시장 전문가입니다.
|
interface NaverShoppingResponse {
|
||||||
사용자가 제공한 장비 정보를 바탕으로 중고 시세를 분석하고 판매 전략을 제안합니다.
|
lastBuildDate: string
|
||||||
|
total: number
|
||||||
분석 항목:
|
start: number
|
||||||
1. **현재 시세 범위**: 해당 장비의 국내 중고 시장 시세 (최저~최고)
|
display: number
|
||||||
2. **희망가 평가**: 제시된 희망가가 적정한지, 높은지, 낮은지 평가
|
items: NaverShoppingItem[]
|
||||||
3. **추천 판매가**: 빠른 판매와 적정 수익을 고려한 추천 가격
|
}
|
||||||
4. **판매 팁**: 플랫폼별 특성을 고려한 판매 전략 및 주의사항
|
|
||||||
|
|
||||||
응답 스타일:
|
|
||||||
- 한국어로 간결하고 실용적으로 답변
|
|
||||||
- 구체적인 가격대와 근거 제시
|
|
||||||
- 마크다운 형식으로 구조화된 답변 제공`
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
const { itemName, platform, salePrice } = body as {
|
const { itemName } = body as { itemName: string; salePrice: number }
|
||||||
itemName: string
|
|
||||||
platform: string
|
if (!itemName?.trim()) {
|
||||||
salePrice: number
|
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}
|
const url = new URL('https://openapi.naver.com/v1/search/shop.json')
|
||||||
- 판매 플랫폼: ${platform}
|
url.searchParams.set('query', itemName)
|
||||||
- 희망 판매가: ${salePrice.toLocaleString('ko-KR')}원`
|
url.searchParams.set('display', '10')
|
||||||
|
url.searchParams.set('sort', 'asc') // 가격 오름차순 (최저가 먼저)
|
||||||
|
url.searchParams.set('exclude', 'used:rental') // 중고·렌탈 제외 → 신품 기준
|
||||||
|
|
||||||
const stream = anthropic.messages.stream({
|
const response = await fetch(url.toString(), {
|
||||||
model: 'claude-sonnet-4-6',
|
headers: {
|
||||||
max_tokens: 1024,
|
'X-Naver-Client-Id': clientId,
|
||||||
system: SYSTEM_PROMPT,
|
'X-Naver-Client-Secret': clientSecret
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user