o2o-infinith-demo/src/lib/transformReport.ts

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: (() => { try { return new URL(metadata.url || '').hostname; } catch { return metadata.url || ''; } })(),
// 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: (() => { try { return new URL(metadata.url || '').hostname; } catch { return metadata.url || ''; } })(),
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;
}