import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo } from '../types/report'; /** * API response from generate-report Edge Function. * The `report` field is AI-generated JSON with varying structure. */ interface ApiReport { clinicInfo?: { name?: string; nameEn?: string; established?: string; address?: string; phone?: string; services?: string[]; doctors?: { name: string; specialty: string; rating?: number; reviews?: number }[]; leadDoctor?: { name: string; specialty: string; rating?: number; reviewCount?: number }; staffCount?: number; }; newChannelProposals?: { channel?: string; priority?: string; rationale?: string }[]; executiveSummary?: string; overallScore?: number; channelAnalysis?: Record; competitors?: { name: string; strengths?: string[]; weaknesses?: string[]; marketingChannels?: string[]; }[]; keywords?: { primary?: { keyword: string; monthlySearches?: number; competition?: string }[]; longTail?: { keyword: string; monthlySearches?: number }[]; }; targetAudience?: { primary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; secondary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; }; recommendations?: { priority?: string; category?: string; title?: string; description?: string; expectedImpact?: string; }[]; brandIdentity?: { area?: string; asIs?: string; toBe?: string; }[]; kpiTargets?: { metric?: string; current?: string; target3Month?: string; target12Month?: string; }[]; marketTrends?: string[]; } interface ApiMetadata { url: string; clinicName: string; generatedAt: string; dataSources?: Record; /** 'registry' = clinic_registry DB 검증 경로. 'scrape' = 실시간 탐색 경로. */ source?: 'registry' | 'scrape'; /** Registry에서 제공된 병원 메타데이터 */ registryData?: { district?: string; branches?: string; brandGroup?: string; websiteEn?: string; naverPlaceUrl?: string; gangnamUnniUrl?: string; googleMapsUrl?: string; } | null; } function scoreToSeverity(score: number | undefined): Severity { if (score === undefined) return 'unknown'; if (score >= 80) return 'excellent'; if (score >= 60) return 'good'; if (score >= 40) return 'warning'; return 'critical'; } function statusToSeverity(status: string | undefined): Severity { switch (status) { case 'active': return 'good'; case 'inactive': return 'critical'; case 'weak': return 'warning'; default: return 'unknown'; } } const CHANNEL_ICONS: Record = { naverBlog: 'blog', instagram: 'instagram', youtube: 'youtube', naverPlace: 'map', gangnamUnni: 'star', website: 'globe', facebook: 'facebook', tiktok: 'video', }; function buildChannelScores(channels: ApiReport['channelAnalysis']): ChannelScore[] { if (!channels) return []; return Object.entries(channels).map(([key, ch]) => ({ channel: key, icon: CHANNEL_ICONS[key] || 'circle', score: ch.score ?? 0, maxScore: 100, status: ch.score !== undefined ? scoreToSeverity(ch.score) : statusToSeverity(ch.status), headline: ch.recommendation || '', })); } function buildDiagnosis(report: ApiReport): DiagnosisItem[] { const items: DiagnosisItem[] = []; // Extract issues from channel analysis if (report.channelAnalysis) { for (const [channel, ch] of Object.entries(report.channelAnalysis)) { // AI-generated per-channel diagnosis array (new format) if (ch.diagnosis) { for (const d of ch.diagnosis) { const issue = typeof d.issue === 'string' ? d.issue : typeof d === 'string' ? d : JSON.stringify(d.issue ?? d); if (issue) { const rec = typeof d.recommendation === 'string' ? d.recommendation : ''; items.push({ category: CHANNEL_ICONS[channel] ? channel : channel, detail: rec ? `${issue} — ${rec}` : issue, severity: (d.severity as Severity) || 'warning', }); } } } // Fallback: single recommendation (old format) else if (ch.status === 'inactive' || ch.status === 'weak') { items.push({ category: channel, detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`, severity: statusToSeverity(ch.status), }); } if (ch.issues) { for (const issue of ch.issues) { items.push({ category: channel, detail: typeof issue === 'string' ? issue : JSON.stringify(issue), severity: 'warning' }); } } } } // Extract from recommendations if (report.recommendations) { for (const rec of report.recommendations) { if (rec.priority === 'high') { items.push({ category: rec.category || '일반', detail: `${rec.title}: ${rec.description}`, severity: 'critical', }); } } } return items; } function buildTransformation(r: ApiReport): import('../types/report').TransformationProposal { const channels = r.channelAnalysis || {}; // Brand Identity — from AI or generate defaults const brandIdentity = (r.brandIdentity || []) .filter((item): item is { area?: string; asIs?: string; toBe?: string } => !!item?.area) .map(item => ({ area: item.area || '', asIs: item.asIs || '', toBe: item.toBe || '' })); if (brandIdentity.length === 0) { brandIdentity.push( { area: '로고 사용', asIs: '채널마다 다른 로고/프로필 이미지 사용', toBe: '공식 로고 가이드 기반 전 채널 통일' }, { area: '컬러 시스템', asIs: '통일된 브랜드 컬러 없음', toBe: '주/보조 컬러 지정 및 전 채널 적용' }, { area: '톤앤매너', asIs: '채널별 다른 커뮤니케이션 스타일', toBe: '브랜드 보이스 가이드 수립 및 적용' }, ); } // Content Strategy const contentStrategy = (r.recommendations || []) .filter(rec => rec.category?.includes('콘텐츠') || rec.category?.includes('content')) .map(rec => ({ area: rec.title || '', asIs: rec.description || '', toBe: rec.expectedImpact || '' })); if (contentStrategy.length === 0) { contentStrategy.push( { area: '콘텐츠 캘린더', asIs: '캘린더 없음, 비정기 업로드', toBe: '주간 콘텐츠 캘린더 운영 (주 5-10건)' }, { area: '숏폼 전략', asIs: 'Shorts/Reels 미운영 또는 미비', toBe: 'YouTube Shorts + Instagram Reels 크로스 포스팅' }, { area: '콘텐츠 다변화', asIs: '단일 포맷 위주', toBe: '수술 전문성 / 환자 후기 / 트렌드 / Q&A 4 pillar 운영' }, ); } // Platform Strategies — rich per-channel const platformStrategies: import('../types/report').PlatformStrategy[] = []; if (channels.youtube) { const subs = channels.youtube.subscribers ?? 0; platformStrategies.push({ platform: 'YouTube', icon: 'youtube', currentMetric: subs > 0 ? `${fmt(subs)} 구독자` : '채널 운영 중', targetMetric: subs > 0 ? `${fmt(Math.round(subs * 2))} 구독자` : '200K 구독자', strategies: [ { strategy: 'Shorts 주 3-5회 업로드', detail: '15-60초 숏폼으로 신규 유입 극대화' }, { strategy: 'Long-form 5-15분 심층 콘텐츠', detail: '상담 연결 → 전환 최적화' }, { strategy: 'VIEW 골드 버튼워크 + 통합 콘텐츠', detail: '구독자 참여형 커뮤니티 활성화' }, ], }); } if (channels.instagram) { const followers = channels.instagram.followers ?? 0; platformStrategies.push({ platform: 'Instagram KR', icon: 'instagram', currentMetric: followers > 0 ? `${fmt(followers)} 팔로워, Reels 0개` : '계정 운영 중', targetMetric: followers > 0 ? `${fmt(Math.round(followers * 3))} 팔로워, Reels 주 5회` : '50K 팔로워', strategies: [ { strategy: 'Reels: YouTube Shorts 동시 게시', detail: '1개 소스 → 2개 채널 자동 배포' }, { strategy: 'Carousel: 시술 가이드 5-7장', detail: '저장/공유 유도형 교육 콘텐츠' }, { strategy: 'Stories: 일상, 비하인드, 투표', detail: '팔로워 인게이지먼트 강화' }, ], }); } if (channels.facebook) { platformStrategies.push({ platform: 'Facebook', icon: 'facebook', currentMetric: `KR ${fmt(channels.facebook.followers ?? 0)}명`, targetMetric: '통합 페이지 + 광고 최적화', strategies: [ { strategy: 'KR 페이지 + EN 페이지 통합 관리', detail: '중복 페이지 정리 및 역할 분리' }, { strategy: 'Facebook Pixel 리타겟팅 광고', detail: '웹사이트 방문자 재타겟팅' }, { strategy: '광고 VIEW 골드로 퍼시 고객 도달', detail: '잠재고객 세그먼트 광고 집행' }, ], }); } // Website improvements const websiteImprovements = (channels.website?.issues || []).map(issue => ({ area: '웹사이트', asIs: issue, toBe: '개선 필요', })); if (websiteImprovements.length === 0) { websiteImprovements.push( { area: 'SNS 연동', asIs: '웹사이트에 SNS 링크 없음', toBe: 'YouTube/Instagram 피드 위젯 + 링크 추가' }, { area: '추적 픽셀', asIs: '광고 추적 미설치', toBe: 'Meta Pixel + Google Analytics 4 설치' }, { area: '상담 전환', asIs: '전화번호만 노출', toBe: '카카오톡 상담 + 온라인 예약 CTA 추가' }, ); } // New channel proposals const newChannelProposals = (r.newChannelProposals || []) .filter((p): p is { channel?: string; priority?: string; rationale?: string } => !!p?.channel) .map(p => ({ channel: p.channel || '', priority: p.priority || 'P2', rationale: p.rationale || '' })); if (newChannelProposals.length === 0) { if (!channels.naverBlog) { newChannelProposals.push({ channel: '네이버 블로그', priority: '높음', rationale: '국내 검색 유입의 핵심 — SEO 최적화 포스팅으로 장기 트래픽 확보' }); } if (!channels.tiktok) { newChannelProposals.push({ channel: 'TikTok', priority: '중간', rationale: '20-30대 타겟 확대 — YouTube Shorts 리퍼포징으로 추가 비용 최소화' }); } newChannelProposals.push({ channel: '카카오톡 채널', priority: '높음', rationale: '상담 전환 직접 채널 — 1:1 상담, 예약 연동, 콘텐츠 푸시' }); } return { brandIdentity, contentStrategy, platformStrategies, websiteImprovements, newChannelProposals }; } /** Safely format KPI values — handles numbers, strings, and nulls from AI output */ function fmtKpi(v: unknown): string { if (v == null) return '-'; if (typeof v === 'number') return fmt(v); return String(v); } function fmt(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; return n.toLocaleString(); } function buildRoadmap(r: ApiReport): import('../types/report').RoadmapMonth[] { const highRecs = (r.recommendations || []).filter(rec => rec.priority === 'high'); const medRecs = (r.recommendations || []).filter(rec => rec.priority === 'medium'); const lowRecs = (r.recommendations || []).filter(rec => rec.priority === 'low'); const channels = r.channelAnalysis || {}; const hasYT = !!channels.youtube; const hasIG = !!channels.instagram; const hasFB = !!channels.facebook; const hasNaver = !!channels.naverBlog; // Month 1: Foundation — brand & infrastructure const m1Tasks = [ '브랜드 아이덴티티 가이드 통합 (로고, 컬러, 폰트, 톤앤매너)', '전 채널 프로필 사진/커버 통일 교체', ...(hasFB ? ['Facebook KR 페이지 정리 (통합 또는 폐쇄)'] : []), ...(hasIG ? [`Instagram KR 팔로잉 정리 (${fmt(channels.instagram?.followers ?? 0)} → 최적화)`] : []), '웹사이트에 YouTube/Instagram 링크 추가', ...(hasYT ? ['기존 YouTube 인기 영상 100개 → AI 숏폼 추출 시작'] : []), '콘텐츠 캘린더 v1 수립', ...highRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), ].filter(Boolean).slice(0, 8); // Month 2: Content Engine — production & distribution const m2Tasks = [ ...(hasYT ? ['YouTube Shorts 주 3~5회 업로드 시작'] : []), ...(hasIG ? ['Instagram Reels 주 5회 업로드 시작'] : []), '검색 결과 쌓을 2차 콘텐츠 스케줄 운영', ...(hasNaver ? ['네이버 블로그 2,000자 이상 SEO 최적화 포스트'] : []), '"리얼 상담실" 시리즈 4회 제작/업로드', '숏폼 콘텐츠 파이프라인 자동화', ...medRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), ].filter(Boolean).slice(0, 7); // Month 3: Optimization — performance & scaling const m3Tasks = [ '전 채널 복합 지표 리뷰 v1', ...(hasIG ? ['Instagram/Facebook 통합 콘텐츠 배포 체계'] : []), ...(hasYT ? ['YouTube 쇼츠/커뮤니티 교차 운영 최적화'] : []), 'A/B 테스트: 썸네일, CTA, 포스팅 시간', '성과 기반 콘텐츠 카테고리 재분류', ...lowRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), ].filter(Boolean).slice(0, 6); return [ { month: 1, title: 'Foundation', subtitle: '기반 구축', tasks: m1Tasks.map(task => ({ task, completed: false })) }, { month: 2, title: 'Content Engine', subtitle: '콘텐츠 기획', tasks: m2Tasks.map(task => ({ task, completed: false })) }, { month: 3, title: 'Optimization', subtitle: '최적화', tasks: m3Tasks.map(task => ({ task, completed: false })) }, ]; } function buildKpiDashboard(r: ApiReport): import('../types/report').KPIMetric[] { // Always build comprehensive KPIs from channel data. // Prefer real enrichment data over AI-guessed channelAnalysis. const channels = r.channelAnalysis || {}; const enrichment = (r as Record).channelEnrichment as Record | undefined; const metrics: import('../types/report').KPIMetric[] = []; // YouTube metrics — prefer enrichment (real API data) over AI guess const ytEnrich = enrichment?.youtube as Record | undefined; const ytSubs = (ytEnrich?.subscribers as number) || ((channels.youtube?.subscribers as number) ?? 0); const ytViews = (ytEnrich?.totalViews as number) || 0; const ytVideos = (ytEnrich?.totalVideos as number) || 0; if (channels.youtube || ytEnrich) { metrics.push({ metric: 'YouTube 구독자', current: ytSubs > 0 ? fmt(ytSubs) : '-', target3Month: ytSubs > 0 ? fmt(Math.round(ytSubs * 1.1)) : '10K', target12Month: ytSubs > 0 ? fmt(Math.round(ytSubs * 2)) : '50K', }); const monthlyViews = ytViews > 0 && ytVideos > 0 ? Math.round(ytViews / Math.max(ytVideos / 12, 1)) : 0; metrics.push({ metric: 'YouTube 월 조회수', current: monthlyViews > 0 ? `~${fmt(monthlyViews)}` : '-', target3Month: monthlyViews > 0 ? fmt(Math.round(monthlyViews * 2)) : '500K', target12Month: monthlyViews > 0 ? fmt(Math.round(monthlyViews * 5)) : '1.5M', }); metrics.push({ metric: 'YouTube Shorts 평균 조회수', current: '500~1,000', target3Month: '5,000', target12Month: '20,000', }); } // Instagram metrics — prefer enrichment data const igAccounts = (enrichment?.instagramAccounts as Record[]) || []; const igPrimary = igAccounts[0] || (enrichment?.instagram as Record) || null; const igSecondary = igAccounts[1] || null; const igFollowers = (igPrimary?.followers as number) || ((channels.instagram?.followers as number) ?? 0); if (channels.instagram || igPrimary) { metrics.push({ metric: 'Instagram KR 팔로워', current: igFollowers > 0 ? fmt(igFollowers) : '-', target3Month: igFollowers > 0 ? fmt(Math.round(igFollowers * 1.4)) : '20K', target12Month: igFollowers > 0 ? fmt(Math.round(igFollowers * 3.5)) : '50K', }); // KR Reels: igtvVideoCount로 운영 여부 판단, 없으면 측정 불가로 표시 const krIgAny = igPrimary as Record | null; const krReels = krIgAny ? ((krIgAny.igtvVideoCount as number) ?? -1) : -1; metrics.push({ metric: 'Instagram KR Reels 평균 조회수', current: krReels > 0 ? `${krReels}개 운영 중 (조회수 측정 불가)` : krReels === 0 ? '0 (미운영)' : '측정 불가', target3Month: '3,000', target12Month: '10,000', }); if (igSecondary) { const enFollowers = (igSecondary.followers as number) || 0; metrics.push({ metric: 'Instagram EN 팔로워', current: enFollowers > 0 ? fmt(enFollowers) : '-', target3Month: enFollowers > 0 ? fmt(Math.round(enFollowers * 1.1)) : '75K', target12Month: enFollowers > 0 ? fmt(Math.round(enFollowers * 1.5)) : '100K', }); } } // Naver Blog — 방문자수는 블로그 소유자만 확인 가능 (비공개), 측정 불가로 표시 if (channels.naverBlog) { const blogStatus = r.channelAnalysis?.naverBlog?.status; const blogPosts = r.channelAnalysis?.naverBlog?.posts; const blogCurrent = blogStatus === 'active' && blogPosts ? `검색 노출 ${fmt(blogPosts)}건 (방문자 비공개)` : '측정 불가'; metrics.push({ metric: '네이버 블로그 방문자', current: blogCurrent, target3Month: '5,000/월', target12Month: '30,000/월', }); } // 강남언니 — prefer enrichment data const guEnrich = enrichment?.gangnamUnni as Record | undefined; const guRating = (guEnrich?.rating as number) || ((channels.gangnamUnni?.rating as number) ?? 0); const guCorrected = typeof guRating === 'number' && guRating > 0 && guRating <= 5 ? guRating * 2 : guRating; const guReviews = (guEnrich?.totalReviews as number) || ((channels.gangnamUnni?.reviews as number) ?? 0); if (channels.gangnamUnni || guEnrich) { metrics.push({ metric: '강남언니 평점', current: guCorrected > 0 ? `${guCorrected}/10` : '-', target3Month: guCorrected > 0 ? `${Math.min(guCorrected + 0.5, 10).toFixed(1)}/10` : '8.0/10', target12Month: guCorrected > 0 ? `${Math.min(guCorrected + 1.0, 10).toFixed(1)}/10` : '9.0/10', }); if (guReviews > 0) { metrics.push({ metric: '강남언니 리뷰 수', current: fmt(guReviews), target3Month: fmt(Math.round(guReviews * 1.15)), target12Month: fmt(Math.round(guReviews * 1.5)), }); } } // 네이버 플레이스 평점 — 목표가 현재보다 낮으면 유지/개선으로 동적 설정 if (channels.naverPlace) { const npRating = channels.naverPlace.rating ?? 0; const npCurrent = npRating ? `${npRating}/5` : '-'; const np3mo = npRating >= 4.8 ? `${npRating}/5 유지` : '4.8/5'; const np12mo = npRating >= 4.9 ? '5.0/5' : '4.9/5'; metrics.push({ metric: '네이버 플레이스 평점', current: npCurrent, target3Month: np3mo, target12Month: np12mo, }); } // Cross-platform — 트래킹 픽셀 미설치 시 측정 불가 const hasTracking = r.channelAnalysis?.website?.trackingPixels && (r.channelAnalysis.website.trackingPixels as unknown[]).length > 0; metrics.push({ metric: '웹사이트 + SNS 유입', current: hasTracking ? '측정 중' : '측정 불가 (트래킹 미설치)', target3Month: '5%', target12Month: '15%', }); metrics.push({ metric: '콘텐츠 → 상담 전환', current: '측정 불가', target3Month: 'UTM 추적 시작', target12Month: '월 50건', }); // Merge AI-provided KPIs that we didn't already cover if (r.kpiTargets?.length) { const existingNames = new Set(metrics.map(m => m.metric.toLowerCase())); for (const k of r.kpiTargets) { if (!k?.metric) continue; // Skip if we already have a similar metric const lower = k.metric.toLowerCase(); if (existingNames.has(lower)) continue; if (lower.includes('youtube') && existingNames.has('youtube 구독자')) continue; if (lower.includes('instagram') && existingNames.has('instagram kr 팔로워')) continue; if (lower.includes('강남언니') || lower.includes('gangnam')) { // Use AI's gangnamunni data — update existing or add const guIdx = metrics.findIndex(m => m.metric.includes('강남언니')); if (guIdx >= 0) { metrics[guIdx] = { metric: k.metric, current: fmtKpi(k.current), target3Month: fmtKpi(k.target3Month), target12Month: fmtKpi(k.target12Month) }; continue; } } metrics.push({ metric: k.metric, current: fmtKpi(k.current), target3Month: fmtKpi(k.target3Month), target12Month: fmtKpi(k.target12Month), }); } } return metrics; } /** * Transform raw API response into the MarketingReport shape * that frontend components expect. */ export function transformApiReport( reportId: string, apiReport: ApiReport, metadata: ApiMetadata, ): MarketingReport { const r = apiReport; const clinic = r.clinicInfo || {}; const doctor = clinic.leadDoctor || clinic.doctors?.[0]; return { id: reportId, createdAt: metadata.generatedAt || new Date().toISOString(), targetUrl: metadata.url, overallScore: r.overallScore ?? 50, clinicSnapshot: { name: clinic.name || metadata.clinicName || '', nameEn: clinic.nameEn || '', // Registry foundedYear takes priority over AI-generated value (Registry = human-verified) established: clinic.established || '', yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0, staffCount: typeof clinic.staffCount === 'number' ? clinic.staffCount : (clinic.doctors?.length ?? 0), leadDoctor: { name: doctor?.name || '', credentials: doctor?.specialty || '', rating: doctor?.rating ?? 0, reviewCount: doctor?.reviewCount ?? doctor?.reviews ?? 0, }, // 강남언니 is 10-point scale. AI sometimes gives 5-point — auto-correct. overallRating: (() => { const raw = r.channelAnalysis?.gangnamUnni?.rating ?? 0; return typeof raw === 'number' && raw > 0 && raw <= 5 ? raw * 2 : raw; })(), totalReviews: r.channelAnalysis?.gangnamUnni?.reviews ?? 0, priceRange: { min: '-', max: '-', currency: '₩' }, certifications: [], mediaAppearances: [], medicalTourism: [], location: clinic.address || '', nearestStation: '', phone: clinic.phone || '', domain: new URL(metadata.url).hostname, // Registry-sourced fields source: metadata.source ?? 'scrape', registryData: metadata.registryData ?? undefined, }, channelScores: buildChannelScores(r.channelAnalysis), youtubeAudit: { channelName: clinic.name || '', handle: '', subscribers: r.channelAnalysis?.youtube?.subscribers ?? 0, totalVideos: 0, totalViews: 0, weeklyViewGrowth: { absolute: 0, percentage: 0 }, estimatedMonthlyRevenue: { min: 0, max: 0 }, avgVideoLength: '-', uploadFrequency: '-', channelCreatedDate: '', subscriberRank: '-', channelDescription: '', linkedUrls: [], playlists: [], topVideos: [], diagnosis: (r.channelAnalysis?.youtube?.recommendation) ? [{ category: 'YouTube', detail: r.channelAnalysis.youtube.recommendation, severity: scoreToSeverity(r.channelAnalysis.youtube.score) }] : [], }, instagramAudit: { accounts: r.channelAnalysis?.instagram ? [{ handle: '', language: 'KR', label: '메인', posts: r.channelAnalysis.instagram.posts ?? 0, followers: r.channelAnalysis.instagram.followers ?? 0, following: 0, category: '의료/건강', profileLink: '', highlights: [], reelsCount: 0, contentFormat: '', profilePhoto: '', bio: '', }] : [], diagnosis: (r.channelAnalysis?.instagram?.recommendation) ? [{ category: 'Instagram', detail: r.channelAnalysis.instagram.recommendation, severity: scoreToSeverity(r.channelAnalysis.instagram.score) }] : [], }, facebookAudit: { pages: [], diagnosis: [], brandInconsistencies: [], consolidationRecommendation: '', }, otherChannels: [ ...(r.channelAnalysis?.naverBlog ? [{ name: '네이버 블로그', status: (r.channelAnalysis.naverBlog.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', details: r.channelAnalysis.naverBlog.recommendation || '', }] : []), ...(r.channelAnalysis?.naverPlace ? [{ name: '네이버 플레이스', status: (r.channelAnalysis.naverPlace.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', details: `평점: ${r.channelAnalysis.naverPlace.rating ?? '-'} / 리뷰: ${r.channelAnalysis.naverPlace.reviews ?? '-'}`, }] : []), ...(r.channelAnalysis?.gangnamUnni ? [{ name: '강남언니', status: (r.channelAnalysis.gangnamUnni.status === 'active' || r.channelAnalysis.gangnamUnni.rating ? 'active' : 'inactive') as 'active' | 'inactive', details: (() => { const raw = r.channelAnalysis?.gangnamUnni?.rating; const rating = typeof raw === 'number' && raw > 0 && raw <= 5 ? raw * 2 : raw; return `평점: ${rating ?? '-'}/10 / 리뷰: ${r.channelAnalysis?.gangnamUnni?.reviews ?? '-'}`; })(), }] : []), ], websiteAudit: { primaryDomain: new URL(metadata.url).hostname, additionalDomains: (r.channelAnalysis?.website?.additionalDomains || []).map(d => ({ domain: (d as { domain?: string }).domain || '', purpose: (d as { purpose?: string }).purpose || '', })), snsLinksOnSite: r.channelAnalysis?.website?.snsLinksOnSite ?? false, trackingPixels: (r.channelAnalysis?.website?.trackingPixels || []).map(p => ({ name: (p as { name?: string }).name || '', installed: (p as { installed?: boolean }).installed ?? false, })), mainCTA: r.channelAnalysis?.website?.mainCTA || '', }, problemDiagnosis: buildDiagnosis(r), transformation: buildTransformation(r), roadmap: buildRoadmap(r), kpiDashboard: buildKpiDashboard(r), screenshots: [], }; } /** * 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; following?: number; posts?: number; bio?: string; isBusinessAccount?: boolean; externalUrl?: string; latestPosts?: { type?: string; likes?: number; comments?: number; caption?: string; timestamp?: string; }[]; }; googleMaps?: { name?: string; rating?: number; reviewCount?: number; address?: string; phone?: string; website?: string; category?: string; openingHours?: unknown; topReviews?: { stars?: number; text?: string; publishedAtDate?: string; }[]; }; youtube?: { channelId?: string; channelName?: string; handle?: string; description?: string; publishedAt?: string; thumbnailUrl?: string; subscribers?: number; totalViews?: number; totalVideos?: number; videos?: { title?: string; views?: number; likes?: number; comments?: number; date?: string; duration?: string; url?: string; thumbnail?: string; }[]; }; gangnamUnni?: { name?: string; rating?: number; ratingScale?: string; totalReviews?: number; doctors?: { name?: string; rating?: number; reviews?: number; specialty?: string }[]; procedures?: string[]; address?: string; 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; }; facebook?: { pageName?: string; pageUrl?: string; followers?: number; likes?: number; categories?: string[]; email?: string; phone?: string; website?: string; address?: string; intro?: string; rating?: number; profilePictureUrl?: string; }; } /** * Generate data-driven diagnosis items from enrichment data. */ function generateEnrichmentDiagnosis(enrichment: EnrichmentData): DiagnosisItem[] { const items: DiagnosisItem[] = []; // YouTube diagnosis if (enrichment.youtube) { const yt = enrichment.youtube; const videos = yt.videos || []; const shorts = videos.filter(v => v.duration && parseInt(v.duration) < 60); const shortsRatio = videos.length > 0 ? (shorts.length / videos.length) * 100 : 0; if (shortsRatio === 0) { items.push({ category: 'YouTube', detail: 'Shorts 콘텐츠가 없습니다. 숏폼 영상은 신규 유입에 가장 효과적인 포맷입니다.', severity: 'warning' }); } if (!yt.description || yt.description.length < 50) { items.push({ category: 'YouTube', detail: '채널 설명이 미비합니다. SEO와 채널 신뢰도를 위해 키워드 포함 설명을 작성하세요.', severity: 'warning' }); } if (yt.totalVideos && yt.totalViews) { const avgViews = yt.totalViews / yt.totalVideos; if (avgViews < 500) { items.push({ category: 'YouTube', detail: `영상당 평균 조회수 ${Math.round(avgViews)}회로 낮습니다. 썸네일과 제목 최적화가 필요합니다.`, severity: 'warning' }); } } if (yt.subscribers && yt.subscribers < 10000) { items.push({ category: 'YouTube', detail: `구독자 ${yt.subscribers.toLocaleString()}명으로 성장 여지가 큽니다. 일관된 업로드 스케줄을 권장합니다.`, severity: 'good' }); } } // Instagram diagnosis const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); for (const ig of igAccounts) { const handle = ig.username || 'Instagram'; if (!ig.bio) { items.push({ category: 'Instagram', detail: `@${handle} 바이오가 비어있습니다. CTA 링크와 소개를 추가하세요.`, severity: 'warning' }); } if (!ig.externalUrl) { items.push({ category: 'Instagram', detail: `@${handle} 외부 링크(웹사이트/예약)가 설정되지 않았습니다.`, severity: 'warning' }); } if (!ig.isBusinessAccount) { items.push({ category: 'Instagram', detail: `@${handle} 비즈니스 계정이 아닙니다. 인사이트 분석을 위해 비즈니스 계정 전환을 권장합니다.`, severity: 'critical' }); } if (ig.followers && ig.followers > 1000 && ig.posts && ig.posts < 30) { items.push({ category: 'Instagram', detail: `@${handle} 팔로워 대비 게시물이 적습니다 (${ig.posts}개). 콘텐츠 업로드 빈도를 높이세요.`, severity: 'warning' }); } } // Facebook diagnosis if (enrichment.facebook) { const fb = enrichment.facebook; if ((fb.followers ?? 0) < 500) { items.push({ category: 'Facebook', detail: `팔로워 ${fb.followers?.toLocaleString() ?? 0}명으로 페이지 활성화가 필요합니다.`, severity: 'warning' }); } if (!fb.intro) { items.push({ category: 'Facebook', detail: 'Facebook 페이지 소개글이 없습니다. 병원 정보와 CTA를 추가하세요.', severity: 'warning' }); } } // 강남언니 diagnosis if (enrichment.gangnamUnni) { const gu = enrichment.gangnamUnni; if (gu.rating && gu.rating < 9.0) { items.push({ category: '강남언니', detail: `평점 ${gu.rating}/10 — 업계 상위권(9.5+) 대비 개선 여지가 있습니다.`, severity: 'warning' }); } if (gu.doctors && gu.doctors.length < 3) { items.push({ category: '강남언니', detail: `등록 전문의 ${gu.doctors.length}명 — 전문의 프로필을 더 등록하면 신뢰도가 높아집니다.`, severity: 'good' }); } if (gu.totalReviews && gu.totalReviews > 5000) { items.push({ category: '강남언니', detail: `리뷰 ${gu.totalReviews.toLocaleString()}건 — 우수한 리뷰 수입니다. 리뷰 관리와 답변에 집중하세요.`, severity: 'excellent' }); } } // Google Maps diagnosis if (enrichment.googleMaps) { const gm = enrichment.googleMaps; if (gm.rating && gm.rating < 4.5) { items.push({ category: 'Google Maps', detail: `평점 ${gm.rating}/5 — 부정 리뷰 대응과 만족도 개선이 필요합니다.`, severity: 'warning' }); } if (gm.reviewCount && gm.reviewCount < 100) { items.push({ category: 'Google Maps', detail: `리뷰 ${gm.reviewCount}건 — 더 많은 환자 리뷰를 유도하세요.`, severity: 'warning' }); } } // Naver diagnosis if (enrichment.naverBlog) { if (enrichment.naverBlog.totalResults && enrichment.naverBlog.totalResults < 100) { items.push({ category: '네이버 블로그', detail: `블로그 검색 노출 ${enrichment.naverBlog.totalResults}건 — SEO 최적화 블로그 포스팅을 늘리세요.`, severity: 'warning' }); } } return items; } /** * Merge enrichment data into an existing MarketingReport. * Returns a new object — does not mutate the original. */ export function mergeEnrichment( report: MarketingReport, enrichment: EnrichmentData, ): MarketingReport { const merged = { ...report }; // Instagram enrichment — multi-account support const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); if (igAccounts.length > 0) { merged.instagramAudit = { ...merged.instagramAudit, accounts: igAccounts.map((ig, idx) => { const igAny = ig as Record; // Reels count: use igtvVideoCount (Instagram merged IGTV into Reels) or count from latestPosts const latestPosts = igAny.latestPosts as { type?: string }[] | undefined; const reelsFromPosts = latestPosts ? latestPosts.filter(p => p.type === 'Video' || p.type === 'Reel').length : 0; const reelsCount = (igAny.igtvVideoCount as number) || reelsFromPosts; return { 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: [], reelsCount, contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정', profilePhoto: '', bio: ig.bio || '', }; }), }; // Update KPI with real follower data from first account const primaryIg = igAccounts[0]; if (primaryIg?.followers) { merged.kpiDashboard = merged.kpiDashboard.map(kpi => kpi.metric.includes('Instagram KR 팔로워') || kpi.metric === 'Instagram 팔로워' ? { ...kpi, current: fmt(primaryIg.followers!), target3Month: fmt(Math.round(primaryIg.followers! * 1.4)), target12Month: fmt(Math.round(primaryIg.followers! * 3.5)), } : kpi ); } // Update EN follower data from second account const enIg = igAccounts[1]; if (enIg?.followers) { merged.kpiDashboard = merged.kpiDashboard.map(kpi => kpi.metric.includes('Instagram EN') ? { ...kpi, current: fmt(enIg.followers!), target3Month: fmt(Math.round(enIg.followers! * 1.1)), target12Month: fmt(Math.round(enIg.followers! * 1.5)), } : kpi ); } } // YouTube enrichment (YouTube Data API v3) if (enrichment.youtube) { const yt = enrichment.youtube; const videos = yt.videos || []; // Parse ISO 8601 duration (PT1H2M3S) to readable format const parseDuration = (iso?: string): string => { if (!iso) return '-'; const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return iso; const h = match[1] ? `${match[1]}:` : ''; const m = match[2] || '0'; const s = (match[3] || '0').padStart(2, '0'); return h ? `${h}${m.padStart(2, '0')}:${s}` : `${m}:${s}`; }; // Check if video is a Short (< 60 seconds) const isShort = (iso?: string): boolean => { if (!iso) return false; const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return false; const totalSec = (parseInt(match[1] || '0') * 3600) + (parseInt(match[2] || '0') * 60) + parseInt(match[3] || '0'); return totalSec <= 60; }; merged.youtubeAudit = { ...merged.youtubeAudit, channelName: yt.channelName || merged.youtubeAudit.channelName, handle: yt.handle || merged.youtubeAudit.handle, subscribers: yt.subscribers ?? merged.youtubeAudit.subscribers, totalVideos: yt.totalVideos ?? merged.youtubeAudit.totalVideos, totalViews: yt.totalViews ?? merged.youtubeAudit.totalViews, channelDescription: yt.description || merged.youtubeAudit.channelDescription, channelCreatedDate: yt.publishedAt ? new Date(yt.publishedAt).toLocaleDateString('ko-KR') : merged.youtubeAudit.channelCreatedDate, topVideos: videos.slice(0, 5).map((v): TopVideo => ({ title: v.title || '', views: v.views || 0, uploadedAgo: v.date ? new Date(v.date).toLocaleDateString('ko-KR') : '', type: isShort(v.duration) ? 'Short' : 'Long', duration: parseDuration(v.duration), })), }; // Update KPI with real YouTube data if (yt.subscribers) { merged.kpiDashboard = merged.kpiDashboard.map(kpi => { if (kpi.metric === 'YouTube 구독자') { return { ...kpi, current: fmt(yt.subscribers!), target3Month: fmt(Math.round(yt.subscribers! * 1.1)), target12Month: fmt(Math.round(yt.subscribers! * 2)), }; } if (kpi.metric === 'YouTube 월 조회수' && yt.totalViews && yt.totalVideos) { const monthlyEstimate = Math.round(yt.totalViews / Math.max((yt.totalVideos / 12), 1)); return { ...kpi, current: `~${fmt(monthlyEstimate)}`, target3Month: fmt(Math.round(monthlyEstimate * 2)), target12Month: fmt(Math.round(monthlyEstimate * 5)), }; } return kpi; }); } } // Google Maps enrichment if (enrichment.googleMaps) { const gm = enrichment.googleMaps; merged.clinicSnapshot = { ...merged.clinicSnapshot, overallRating: gm.rating ?? merged.clinicSnapshot.overallRating, totalReviews: gm.reviewCount ?? merged.clinicSnapshot.totalReviews, phone: gm.phone || merged.clinicSnapshot.phone, location: gm.address || merged.clinicSnapshot.location, }; // Update or add Google Maps to otherChannels const gmChannelIdx = merged.otherChannels.findIndex(c => c.name === '구글 지도'); const gmChannel = { name: '구글 지도', status: 'active' as const, details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`, // Use Maps URL from enrichment if available, fallback to search URL url: (gm as Record).mapsUrl ? String((gm as Record).mapsUrl) : gm.name ? `https://www.google.com/maps/search/${encodeURIComponent(String(gm.name))}` : '', }; if (gmChannelIdx >= 0) { merged.otherChannels[gmChannelIdx] = gmChannel; } else { merged.otherChannels = [...merged.otherChannels, gmChannel]; } } // 강남언니 enrichment if (enrichment.gangnamUnni) { const gu = enrichment.gangnamUnni; // Update clinic snapshot with real gangnamUnni data merged.clinicSnapshot = { ...merged.clinicSnapshot, overallRating: gu.rating ?? merged.clinicSnapshot.overallRating, totalReviews: gu.totalReviews ?? merged.clinicSnapshot.totalReviews, certifications: gu.badges?.length ? gu.badges : merged.clinicSnapshot.certifications, staffCount: gu.doctors?.length ?? merged.clinicSnapshot.staffCount, // 전문의 수 (강남언니 등록 의사 기준) }; // Extract nearest station from address (Korean station name pattern) const addressToSearch = gu.address || merged.clinicSnapshot.location; if (addressToSearch && !merged.clinicSnapshot.nearestStation) { const stationMatch = addressToSearch.match(/(\S+역)/); if (stationMatch) { merged.clinicSnapshot = { ...merged.clinicSnapshot, nearestStation: stationMatch[1] }; } } // Update lead doctor with gangnamUnni doctor data if (gu.doctors?.length) { const topDoctor = gu.doctors[0]; if (topDoctor?.name) { merged.clinicSnapshot = { ...merged.clinicSnapshot, leadDoctor: { name: topDoctor.name, credentials: topDoctor.specialty || merged.clinicSnapshot.leadDoctor.credentials, rating: topDoctor.rating ?? 0, reviewCount: topDoctor.reviews ?? 0, }, }; } } // Update gangnamUnni channel in otherChannels const guChannelIdx = merged.otherChannels.findIndex(c => c.name === '강남언니'); const guChannel = { name: '강남언니', status: 'active' as const, details: `평점: ${gu.rating ?? '-'}${gu.ratingScale || '/10'} / 리뷰: ${gu.totalReviews?.toLocaleString() ?? '-'}건`, url: gu.sourceUrl || '', }; if (guChannelIdx >= 0) { merged.otherChannels[guChannelIdx] = guChannel; } else { merged.otherChannels = [...merged.otherChannels, guChannel]; } } // Facebook enrichment if (enrichment.facebook) { const fb = enrichment.facebook; merged.facebookAudit = { ...merged.facebookAudit, pages: [{ url: fb.pageUrl || '', pageName: fb.pageName || '', language: 'KR', label: '메인', followers: fb.followers ?? 0, following: 0, category: fb.categories?.join(', ') || '', bio: fb.intro || '', logo: '', logoDescription: '', link: fb.website || '', linkedDomain: fb.website || '', reviews: (() => { // Facebook rating 문자열 파싱: "Not yet rated (3 Reviews)" or "4.8 (120 Reviews)" const m = (fb.rating || '').match(/\((\d+)\s+Reviews?\)/i); return m ? parseInt(m[1], 10) : 0; })(), recentPostAge: '', hasWhatsApp: false, }], }; } // 네이버 블로그 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}개`, // Prefer official blog URL from Phase 1, fallback to search URL url: (nb as Record).officialBlogUrl ? String((nb as Record).officialBlogUrl) : nb.searchQuery ? `https://search.naver.com/search.naver?where=blog&query=${encodeURIComponent(String(nb.searchQuery))}` : '', }; 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 || '', // np.link is the clinic's own website, NOT Naver Place page // Use Naver Place search URL instead url: np.name ? `https://map.naver.com/v5/search/${encodeURIComponent(String(np.name))}` : '', }; 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 }; } } // Generate data-driven diagnosis from enrichment data const enrichDiagnosis = generateEnrichmentDiagnosis(enrichment); if (enrichDiagnosis.length > 0) { merged.problemDiagnosis = [...merged.problemDiagnosis, ...enrichDiagnosis]; } return merged; }