1269 lines
49 KiB
TypeScript
1269 lines
49 KiB
TypeScript
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<string, {
|
|
score?: number;
|
|
status?: string;
|
|
posts?: number;
|
|
followers?: number;
|
|
subscribers?: number;
|
|
rating?: number;
|
|
reviews?: number;
|
|
issues?: string[];
|
|
recommendation?: string;
|
|
diagnosis?: { issue?: string; severity?: string; recommendation?: string }[];
|
|
trackingPixels?: { name: string; installed: boolean }[];
|
|
snsLinksOnSite?: boolean;
|
|
additionalDomains?: { domain: string; purpose: string }[];
|
|
mainCTA?: string;
|
|
}>;
|
|
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<string, boolean>;
|
|
/** '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<string, string> = {
|
|
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 === 'string' ? d : typeof d.issue === 'string' ? d.issue : null;
|
|
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) {
|
|
const issueObj = issue as Record<string, unknown>;
|
|
const detail = typeof issue === 'string'
|
|
? issue
|
|
: typeof issueObj.issue === 'string'
|
|
? issueObj.issue
|
|
: null;
|
|
if (detail) {
|
|
items.push({ category: channel, detail, severity: (issueObj.severity as 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<string, unknown>).channelEnrichment as Record<string, unknown> | undefined;
|
|
const metrics: import('../types/report').KPIMetric[] = [];
|
|
|
|
// YouTube metrics — prefer enrichment (real API data) over AI guess
|
|
const ytEnrich = enrichment?.youtube as Record<string, unknown> | 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<string, unknown>[]) || [];
|
|
const igPrimary = igAccounts[0] || (enrichment?.instagram as Record<string, unknown>) || 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<string, unknown> | 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<string, unknown> | 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 as { reviewCount?: number })?.reviewCount ?? (doctor as { reviews?: number })?.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;
|
|
};
|
|
// 스크래핑 시 캡처된 스크린샷 목록 (channel_data.screenshots)
|
|
screenshots?: {
|
|
id: string;
|
|
url: string;
|
|
channel: string;
|
|
caption: string;
|
|
capturedAt?: 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<string, unknown>;
|
|
// 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<string, unknown>).mapsUrl
|
|
? String((gm as Record<string, unknown>).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 = String(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<string, unknown>).officialBlogUrl
|
|
? String((nb as Record<string, unknown>).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];
|
|
}
|
|
|
|
// ── 스크린샷 영구 반영 ──────────────────────────────────────────────────────
|
|
// channel_data.screenshots → report.screenshots 로 옮기고,
|
|
// 채널별로 diagnosis evidenceIds 자동 연결
|
|
if (enrichment.screenshots?.length) {
|
|
const ss = enrichment.screenshots;
|
|
|
|
// 1) report.screenshots 세팅 (ScreenshotEvidence 형식으로 변환)
|
|
merged.screenshots = ss.map(s => ({
|
|
id: s.id,
|
|
url: s.url,
|
|
channel: s.channel,
|
|
caption: s.caption,
|
|
capturedAt: s.capturedAt ?? new Date().toISOString(),
|
|
sourceUrl: s.sourceUrl,
|
|
}));
|
|
|
|
// 2) 채널명 → screenshot IDs 매핑 테이블 생성
|
|
// channel_data의 channel 필드: "YouTube", "웹사이트", "Instagram", "Facebook" 등
|
|
const CHANNEL_ALIAS: Record<string, string[]> = {
|
|
youtube: ['youtube', 'YouTube', 'yt'],
|
|
instagram: ['instagram', 'Instagram', 'ig'],
|
|
facebook: ['facebook', 'Facebook', 'fb'],
|
|
website: ['웹사이트', 'website', 'Website'],
|
|
gangnamUnni: ['강남언니', 'gangnamUnni'],
|
|
naverPlace: ['네이버 플레이스', 'naverPlace'],
|
|
naverBlog: ['네이버 블로그', 'naverBlog'],
|
|
};
|
|
|
|
const channelToIds: Record<string, string[]> = {};
|
|
for (const s of ss) {
|
|
for (const [key, aliases] of Object.entries(CHANNEL_ALIAS)) {
|
|
if (aliases.some(a => s.channel.toLowerCase().includes(a.toLowerCase()))) {
|
|
channelToIds[key] = [...(channelToIds[key] ?? []), s.id];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) 채널별 audit.diagnosis 배열에 evidenceIds 연결
|
|
// YouTubeAudit / InstagramAudit / FacebookAudit 컴포넌트가 이 필드를 사용함
|
|
const linkIds = (diagItems: import('../types/report').DiagnosisItem[], channelKey: string): import('../types/report').DiagnosisItem[] => {
|
|
const ids = channelToIds[channelKey] ?? [];
|
|
if (!ids.length) return diagItems;
|
|
return diagItems.map(item => ({ ...item, evidenceIds: [...(item.evidenceIds ?? []), ...ids] }));
|
|
};
|
|
|
|
if (merged.youtubeAudit?.diagnosis?.length) {
|
|
merged.youtubeAudit = { ...merged.youtubeAudit, diagnosis: linkIds(merged.youtubeAudit.diagnosis, 'youtube') };
|
|
}
|
|
if (merged.instagramAudit?.diagnosis?.length) {
|
|
merged.instagramAudit = { ...merged.instagramAudit, diagnosis: linkIds(merged.instagramAudit.diagnosis, 'instagram') };
|
|
}
|
|
if (merged.facebookAudit?.diagnosis?.length) {
|
|
merged.facebookAudit = { ...merged.facebookAudit, diagnosis: linkIds(merged.facebookAudit.diagnosis, 'facebook') };
|
|
}
|
|
// websiteAudit / 기타 채널은 EvidenceGallery를 직접 받지 않으므로 problemDiagnosis에만 연결
|
|
merged.problemDiagnosis = merged.problemDiagnosis.map(item => {
|
|
const catLower = item.category.toLowerCase();
|
|
let ids: string[] = [];
|
|
for (const [key, ssIds] of Object.entries(channelToIds)) {
|
|
if (catLower.includes(key) || key.includes(catLower)) {
|
|
ids = [...ids, ...ssIds];
|
|
}
|
|
}
|
|
return ids.length > 0 ? { ...item, evidenceIds: ids } : item;
|
|
});
|
|
}
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
return merged;
|
|
}
|