diff --git a/src/components/CTA.tsx b/src/components/CTA.tsx index dd75bec..0f6f544 100644 --- a/src/components/CTA.tsx +++ b/src/components/CTA.tsx @@ -52,9 +52,13 @@ export default function CTA() { onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()} className="w-full px-8 py-4 text-base font-medium bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] border border-white/20 rounded-full focus:outline-none focus:ring-2 focus:ring-white/50 shadow-sm text-center text-primary-900 placeholder:text-primary-900/60" /> -

네이버 블로그, 플레이스, 소셜미디어 종합 분석 리포트 받아보기 diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 1fc303f..b205bbd 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -64,9 +64,13 @@ export default function Hero() { className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300" /> -

네이버 블로그, 플레이스, 소셜미디어 등 Online Presence 종합 분석 리포트를 제공합니다. diff --git a/src/hooks/useEnrichment.ts b/src/hooks/useEnrichment.ts index ebb75e2..25e4f77 100644 --- a/src/hooks/useEnrichment.ts +++ b/src/hooks/useEnrichment.ts @@ -14,6 +14,7 @@ interface EnrichmentParams { reportId: string | null; clinicName: string; instagramHandle?: string; + instagramHandles?: string[]; youtubeChannelId?: string; address?: string; } @@ -34,7 +35,7 @@ export function useEnrichment( useEffect(() => { if (!baseReport || !params?.reportId || hasTriggered.current) return; // Don't enrich if no social handles are available - if (!params.instagramHandle && !params.youtubeChannelId) return; + if (!params.instagramHandle && !params.instagramHandles?.length && !params.youtubeChannelId) return; hasTriggered.current = true; setStatus('loading'); @@ -43,6 +44,7 @@ export function useEnrichment( reportId: params.reportId, clinicName: params.clinicName, instagramHandle: params.instagramHandle, + instagramHandles: params.instagramHandles, youtubeChannelId: params.youtubeChannelId, address: params.address, }) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 0f4ab89..15083b7 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -37,6 +37,7 @@ export interface EnrichChannelsRequest { reportId: string; clinicName: string; instagramHandle?: string; + instagramHandles?: string[]; youtubeChannelId?: string; address?: string; } diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts index 3aae6ca..9130394 100644 --- a/src/lib/transformReport.ts +++ b/src/lib/transformReport.ts @@ -347,6 +347,15 @@ export function transformApiReport( * Enrichment data shape from enrich-channels Edge Function. */ export interface EnrichmentData { + instagramAccounts?: { + username?: string; + followers?: number; + following?: number; + posts?: number; + bio?: string; + isBusinessAccount?: boolean; + externalUrl?: string; + }[]; instagram?: { username?: string; followers?: number; @@ -410,6 +419,26 @@ export interface EnrichmentData { badges?: string[]; sourceUrl?: string; }; + naverBlog?: { + totalResults?: number; + searchQuery?: string; + posts?: { + title?: string; + description?: string; + link?: string; + bloggerName?: string; + postDate?: string; + }[]; + }; + naverPlace?: { + name?: string; + category?: string; + address?: string; + telephone?: string; + link?: string; + mapx?: string; + mapy?: string; + }; } /** @@ -422,20 +451,18 @@ export function mergeEnrichment( ): MarketingReport { const merged = { ...report }; - // Instagram enrichment - if (enrichment.instagram) { - const ig = enrichment.instagram; - const existingAccount = merged.instagramAudit.accounts[0]; - + // Instagram enrichment — multi-account support + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + if (igAccounts.length > 0) { merged.instagramAudit = { ...merged.instagramAudit, - accounts: [{ - handle: ig.username || existingAccount?.handle || '', - language: 'KR', - label: '메인', - posts: ig.posts ?? existingAccount?.posts ?? 0, - followers: ig.followers ?? existingAccount?.followers ?? 0, - following: ig.following ?? existingAccount?.following ?? 0, + accounts: igAccounts.map((ig, idx) => ({ + handle: ig.username || '', + language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN', + label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`, + posts: ig.posts ?? 0, + followers: ig.followers ?? 0, + following: ig.following ?? 0, category: '의료/건강', profileLink: ig.username ? `https://instagram.com/${ig.username}` : '', highlights: [], @@ -443,7 +470,7 @@ export function mergeEnrichment( contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정', profilePhoto: '', bio: ig.bio || '', - }], + })), }; // Update KPI with real follower data @@ -589,5 +616,44 @@ export function mergeEnrichment( } } + // 네이버 블로그 enrichment + if (enrichment.naverBlog) { + const nb = enrichment.naverBlog; + const nbChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 블로그'); + const nbChannel = { + name: '네이버 블로그', + status: 'active' as const, + details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}개`, + url: '', + }; + if (nbChannelIdx >= 0) { + merged.otherChannels[nbChannelIdx] = nbChannel; + } else { + merged.otherChannels = [...merged.otherChannels, nbChannel]; + } + } + + // 네이버 플레이스 enrichment + if (enrichment.naverPlace) { + const np = enrichment.naverPlace; + const npChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 플레이스'); + const npChannel = { + name: '네이버 플레이스', + status: 'active' as const, + details: np.category || '', + url: np.link || '', + }; + if (npChannelIdx >= 0) { + merged.otherChannels[npChannelIdx] = npChannel; + } else { + merged.otherChannels = [...merged.otherChannels, npChannel]; + } + + // Update clinic phone/address from Naver Place if available + if (np.telephone) { + merged.clinicSnapshot = { ...merged.clinicSnapshot, phone: np.telephone }; + } + } + return merged; } diff --git a/src/pages/ReportPage.tsx b/src/pages/ReportPage.tsx index bf15dbe..f6f879b 100644 --- a/src/pages/ReportPage.tsx +++ b/src/pages/ReportPage.tsx @@ -53,10 +53,10 @@ export default function ReportPage() { const handles = stateSocialHandles || dbSocialHandles; - const igHandle = - handles?.instagram || - baseData.instagramAudit?.accounts?.[0]?.handle || - undefined; + // Instagram: support array of handles (multi-account) or single handle + const igHandles: string[] = Array.isArray(handles?.instagram) + ? handles.instagram.filter(Boolean) as string[] + : handles?.instagram ? [handles.instagram as string] : []; const ytHandle = handles?.youtube || @@ -66,7 +66,7 @@ export default function ReportPage() { return { reportId: baseData.id, clinicName: baseData.clinicSnapshot.name, - instagramHandle: igHandle || undefined, + instagramHandles: igHandles.length > 0 ? igHandles : undefined, youtubeChannelId: ytHandle || undefined, address: baseData.clinicSnapshot.location || undefined, }; diff --git a/supabase/functions/enrich-channels/index.ts b/supabase/functions/enrich-channels/index.ts index 3e9d4a1..a17174d 100644 --- a/supabase/functions/enrich-channels/index.ts +++ b/supabase/functions/enrich-channels/index.ts @@ -14,6 +14,7 @@ interface EnrichRequest { reportId: string; clinicName: string; instagramHandle?: string; + instagramHandles?: string[]; youtubeChannelId?: string; address?: string; } @@ -47,9 +48,14 @@ Deno.serve(async (req) => { } try { - const { reportId, clinicName, instagramHandle, youtubeChannelId, address } = + const { reportId, clinicName, instagramHandle, instagramHandles, youtubeChannelId, address } = (await req.json()) as EnrichRequest; + // Build list of IG handles to try: explicit array > single handle > empty + const igHandlesToTry: string[] = instagramHandles?.length + ? instagramHandles + : instagramHandle ? [instagramHandle] : []; + const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN"); if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured"); @@ -58,55 +64,70 @@ Deno.serve(async (req) => { // Run all enrichment tasks in parallel const tasks = []; - // 1. Instagram Profile — with fallback for wrong handle - const cleanIgHandle = normalizeInstagramHandle(instagramHandle); - if (cleanIgHandle) { + // 1. Instagram Profiles — multi-account with fallback + if (igHandlesToTry.length > 0) { tasks.push( (async () => { - // Try the given handle first, then common clinic variants - const handleCandidates = [ - cleanIgHandle, - `${cleanIgHandle}_ps`, // banobagi → banobagi_ps - `${cleanIgHandle}.ps`, // banobagi → banobagi.ps - `${cleanIgHandle}_clinic`, // banobagi → banobagi_clinic - `${cleanIgHandle}_official`, // banobagi → banobagi_official - ]; + const accounts: Record[] = []; + const triedHandles = new Set(); - for (const handle of handleCandidates) { - const items = await runApifyActor( - "apify~instagram-profile-scraper", - { usernames: [handle], resultsLimit: 12 }, - APIFY_TOKEN - ); - const profile = (items as Record[])[0]; + for (const rawHandle of igHandlesToTry) { + const baseHandle = normalizeInstagramHandle(rawHandle); + if (!baseHandle || triedHandles.has(baseHandle)) continue; - if (profile && !profile.error) { - const followers = (profile.followersCount as number) || 0; + // Try the handle + common clinic variants + const candidates = [ + baseHandle, + `${baseHandle}_ps`, + `${baseHandle}.ps`, + `${baseHandle}_clinic`, + `${baseHandle}_official`, + ]; - // Accept if: has meaningful followers OR is a business account with posts - if (followers >= 100 || ((profile.isBusinessAccount as boolean) && (profile.postsCount as number) > 10)) { - enrichment.instagram = { - username: profile.username, - followers: profile.followersCount, - following: profile.followsCount, - posts: profile.postsCount, - bio: profile.biography, - isBusinessAccount: profile.isBusinessAccount, - externalUrl: profile.externalUrl, - latestPosts: ((profile.latestPosts as Record[]) || []) - .slice(0, 12) - .map((p) => ({ - type: p.type, - likes: p.likesCount, - comments: p.commentsCount, - caption: (p.caption as string || "").slice(0, 200), - timestamp: p.timestamp, - })), - }; - break; // Found a valid account + for (const handle of candidates) { + if (triedHandles.has(handle)) continue; + triedHandles.add(handle); + + const items = await runApifyActor( + "apify~instagram-profile-scraper", + { usernames: [handle], resultsLimit: 12 }, + APIFY_TOKEN + ); + const profile = (items as Record[])[0]; + + if (profile && !profile.error) { + const followers = (profile.followersCount as number) || 0; + if (followers >= 100 || ((profile.isBusinessAccount as boolean) && (profile.postsCount as number) > 10)) { + accounts.push({ + username: profile.username, + followers: profile.followersCount, + following: profile.followsCount, + posts: profile.postsCount, + bio: profile.biography, + isBusinessAccount: profile.isBusinessAccount, + externalUrl: profile.externalUrl, + latestPosts: ((profile.latestPosts as Record[]) || []) + .slice(0, 6) + .map((p) => ({ + type: p.type, + likes: p.likesCount, + comments: p.commentsCount, + caption: (p.caption as string || "").slice(0, 200), + timestamp: p.timestamp, + })), + }); + break; // Found valid for this base handle, move to next + } } } } + + // Store as array for multi-account support + if (accounts.length > 0) { + enrichment.instagramAccounts = accounts; + // Keep backwards compat: first account as enrichment.instagram + enrichment.instagram = accounts[0]; + } })() ); } @@ -245,7 +266,68 @@ Deno.serve(async (req) => { } } - // 4. YouTube Channel (using YouTube Data API v3) + // 4. Naver Blog + Place Search (네이버 검색 API) + if (clinicName) { + const NAVER_CLIENT_ID = Deno.env.get("NAVER_CLIENT_ID"); + const NAVER_CLIENT_SECRET = Deno.env.get("NAVER_CLIENT_SECRET"); + if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET) { + const naverHeaders = { + "X-Naver-Client-Id": NAVER_CLIENT_ID, + "X-Naver-Client-Secret": NAVER_CLIENT_SECRET, + }; + + // 4a. Blog search — "{clinicName} 후기" + tasks.push( + (async () => { + const query = encodeURIComponent(`${clinicName} 후기`); + const res = await fetch( + `https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, + { headers: naverHeaders } + ); + if (!res.ok) return; + const data = await res.json(); + enrichment.naverBlog = { + totalResults: data.total || 0, + searchQuery: `${clinicName} 후기`, + posts: (data.items || []).slice(0, 10).map((item: Record) => ({ + title: (item.title || "").replace(/<[^>]*>/g, ""), + description: (item.description || "").replace(/<[^>]*>/g, "").slice(0, 200), + link: item.link, + bloggerName: item.bloggername, + postDate: item.postdate, + })), + }; + })() + ); + + // 4b. Local search — Naver Place + tasks.push( + (async () => { + const query = encodeURIComponent(clinicName); + const res = await fetch( + `https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, + { headers: naverHeaders } + ); + if (!res.ok) return; + const data = await res.json(); + const place = (data.items || [])[0]; + if (place) { + enrichment.naverPlace = { + name: (place.title || "").replace(/<[^>]*>/g, ""), + category: place.category, + address: place.roadAddress || place.address, + telephone: place.telephone, + link: place.link, + mapx: place.mapx, + mapy: place.mapy, + }; + } + })() + ); + } + } + + // 5. YouTube Channel (using YouTube Data API v3) if (youtubeChannelId) { const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY"); if (YOUTUBE_API_KEY) { diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts index 0d9c533..c422ddd 100644 --- a/supabase/functions/generate-report/index.ts +++ b/supabase/functions/generate-report/index.ts @@ -94,7 +94,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)} "services": ["시술1", "시술2"], "doctors": [{"name": "의사명", "specialty": "전문분야"}], "socialMedia": { - "instagram": "정확한 Instagram 핸들 (@ 없이, 예: banobagi_ps)", + "instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"], "youtube": "YouTube 채널 핸들 또는 URL", "facebook": "Facebook 페이지명", "naverBlog": "네이버 블로그 ID" @@ -174,8 +174,21 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)} // Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback) const scrapeSocial = clinic.socialMedia || {}; const aiSocial = report?.clinicInfo?.socialMedia || {}; + + // Instagram: collect all accounts from AI + Firecrawl, deduplicate + const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts) + ? aiSocial.instagramAccounts + : aiSocial.instagram ? [aiSocial.instagram] : []; + const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : []; + const allIgRaw = [...aiIgAccounts, ...scrapeIg]; + const igHandles = [...new Set( + allIgRaw + .map((h: string) => normalizeInstagramHandle(h)) + .filter((h): h is string => h !== null) + )]; + const normalizedHandles = { - instagram: normalizeInstagramHandle(aiSocial.instagram) || normalizeInstagramHandle(scrapeSocial.instagram), + instagram: igHandles.length > 0 ? igHandles : null, // array of handles youtube: aiSocial.youtube || scrapeSocial.youtube || null, facebook: aiSocial.facebook || scrapeSocial.facebook || null, blog: aiSocial.naverBlog || scrapeSocial.blog || null,