523 lines
17 KiB
TypeScript
523 lines
17 KiB
TypeScript
/**
|
||
* Content Director Engine v2
|
||
*
|
||
* Deterministic content planning agent that generates a 4-week editorial
|
||
* calendar by combining channel strategies, content pillars, clinic services,
|
||
* and existing assets. No AI API calls — all logic is data-driven.
|
||
*
|
||
* v2 changes (Audit-driven):
|
||
* - Added 강남언니, TikTok, YouTube Live/Community slots
|
||
* - Added Instagram Feed slot, Naver column format
|
||
* - Split Facebook into organic/ad
|
||
* - 5th Pillar: 안전·케어
|
||
* - Customer journey-based week themes
|
||
* - Channel-specific tone matrix
|
||
* - Keyword-driven topic generation
|
||
*/
|
||
|
||
import type {
|
||
ChannelStrategyCard,
|
||
ContentPillar,
|
||
CalendarWeek,
|
||
CalendarEntry,
|
||
ContentCountSummary,
|
||
ContentCategory,
|
||
} from '../types/plan';
|
||
|
||
// ─── Input / Output Types ───
|
||
|
||
export interface ContentDirectorInput {
|
||
channels: ChannelStrategyCard[];
|
||
pillars: ContentPillar[];
|
||
services: string[];
|
||
youtubeVideos?: { title: string; views: number; type: 'Short' | 'Long' }[];
|
||
clinicName: string;
|
||
keywords?: { keyword: string; monthlySearches?: number }[];
|
||
gangnamUnniData?: { rating?: number; reviews?: number; doctors?: number };
|
||
}
|
||
|
||
export interface ContentDirectorOutput {
|
||
weeks: CalendarWeek[];
|
||
monthlySummary: ContentCountSummary[];
|
||
}
|
||
|
||
// ─── Channel-Format Slot Definitions ───
|
||
|
||
interface FormatSlot {
|
||
channel: string;
|
||
channelIcon: string;
|
||
format: string;
|
||
contentType: ContentCategory;
|
||
preferredDays: number[]; // 0=월 ~ 6=일
|
||
perWeek: number;
|
||
titleTemplate: (topic: string, idx: number) => string;
|
||
tone: string;
|
||
journeyStage: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty';
|
||
}
|
||
|
||
// ─── YouTube: 4 formats ───
|
||
const YOUTUBE_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: 'YouTube', channelIcon: 'youtube', format: 'Shorts',
|
||
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
||
titleTemplate: (t, i) => `Shorts: ${t} #${i + 1}`,
|
||
tone: '캐주얼·후킹', journeyStage: 'awareness',
|
||
},
|
||
{
|
||
channel: 'YouTube', channelIcon: 'youtube', format: 'Long-form',
|
||
contentType: 'video', preferredDays: [3], perWeek: 1,
|
||
titleTemplate: (t) => `${t} 상세 설명`,
|
||
tone: '교육적·권위', journeyStage: 'interest',
|
||
},
|
||
{
|
||
channel: 'YouTube', channelIcon: 'youtube', format: 'Live Q&A',
|
||
contentType: 'video', preferredDays: [5], perWeek: 1,
|
||
titleTemplate: (t) => `Live: ${t} 실시간 Q&A`,
|
||
tone: '친근·대화', journeyStage: 'consideration',
|
||
},
|
||
{
|
||
channel: 'YouTube', channelIcon: 'youtube', format: 'Community',
|
||
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
|
||
titleTemplate: (t) => `[커뮤니티] ${t} 투표/질문`,
|
||
tone: '친근·참여유도', journeyStage: 'loyalty',
|
||
},
|
||
];
|
||
|
||
// ─── Instagram: 4 formats ───
|
||
const INSTAGRAM_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: 'Instagram', channelIcon: 'instagram', format: 'Reel',
|
||
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
||
titleTemplate: (t, i) => `Reel: ${t} #${i + 1}`,
|
||
tone: '트렌디·공감', journeyStage: 'awareness',
|
||
},
|
||
{
|
||
channel: 'Instagram', channelIcon: 'instagram', format: 'Carousel',
|
||
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
|
||
titleTemplate: (t) => `Carousel: ${t}`,
|
||
tone: '정보성·교육', journeyStage: 'interest',
|
||
},
|
||
{
|
||
channel: 'Instagram', channelIcon: 'instagram', format: 'Feed',
|
||
contentType: 'social', preferredDays: [3], perWeek: 1,
|
||
titleTemplate: (t) => `Feed: ${t} 포트폴리오`,
|
||
tone: '감성적·프리미엄', journeyStage: 'consideration',
|
||
},
|
||
{
|
||
channel: 'Instagram', channelIcon: 'instagram', format: 'Stories',
|
||
contentType: 'social', preferredDays: [2, 5], perWeek: 2,
|
||
titleTemplate: (t) => `Stories: ${t}`,
|
||
tone: '친근·일상', journeyStage: 'loyalty',
|
||
},
|
||
];
|
||
|
||
// ─── 강남언니: 3 formats (Audit C1) ───
|
||
const GANGNAMUNNI_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: '강남언니', channelIcon: 'star', format: '리뷰관리',
|
||
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
|
||
titleTemplate: (t) => `리뷰 응대: ${t}`,
|
||
tone: '전문·응대', journeyStage: 'consideration',
|
||
},
|
||
{
|
||
channel: '강남언니', channelIcon: 'star', format: '프로필최적화',
|
||
contentType: 'social', preferredDays: [0], perWeek: 1,
|
||
titleTemplate: (t) => `프로필 업데이트: ${t}`,
|
||
tone: '전문·신뢰', journeyStage: 'consideration',
|
||
},
|
||
{
|
||
channel: '강남언니', channelIcon: 'star', format: '이벤트/가격',
|
||
contentType: 'ad', preferredDays: [3], perWeek: 1,
|
||
titleTemplate: (t) => `이벤트: ${t}`,
|
||
tone: '프로모션·CTA', journeyStage: 'conversion',
|
||
},
|
||
];
|
||
|
||
// ─── TikTok: 1 format (Audit C2) ───
|
||
const TIKTOK_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: 'TikTok', channelIcon: 'video', format: '숏폼',
|
||
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
|
||
titleTemplate: (t, i) => `TikTok: ${t} #${i + 1}`,
|
||
tone: '밈·교육', journeyStage: 'awareness',
|
||
},
|
||
];
|
||
|
||
// ─── 네이버 블로그: 2 formats (Audit H3) ───
|
||
const NAVER_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: '네이버 블로그', channelIcon: 'blog', format: 'SEO글',
|
||
contentType: 'blog', preferredDays: [1, 3], perWeek: 2,
|
||
titleTemplate: (t) => `${t}`,
|
||
tone: '정보성·SEO', journeyStage: 'interest',
|
||
},
|
||
{
|
||
channel: '네이버 블로그', channelIcon: 'blog', format: '의사칼럼',
|
||
contentType: 'blog', preferredDays: [5], perWeek: 1,
|
||
titleTemplate: (t) => `[칼럼] ${t}`,
|
||
tone: '전문·교육', journeyStage: 'consideration',
|
||
},
|
||
];
|
||
|
||
// ─── Facebook: organic + ad (Audit H4) ───
|
||
const FACEBOOK_SLOTS: FormatSlot[] = [
|
||
{
|
||
channel: 'Facebook', channelIcon: 'facebook', format: '오가닉',
|
||
contentType: 'social', preferredDays: [2], perWeek: 1,
|
||
titleTemplate: (t) => `${t}`,
|
||
tone: '커뮤니티·공유', journeyStage: 'interest',
|
||
},
|
||
{
|
||
channel: 'Facebook', channelIcon: 'facebook', format: '광고',
|
||
contentType: 'ad', preferredDays: [4, 6], perWeek: 2,
|
||
titleTemplate: (t) => `광고: ${t}`,
|
||
tone: '타겟팅·CTA', journeyStage: 'conversion',
|
||
},
|
||
];
|
||
|
||
// ─── Customer Journey-Based Week Themes (Audit C4) ───
|
||
|
||
const WEEK_THEMES = [
|
||
{
|
||
label: 'Week 1: 인지 확대 & 브랜드 정비',
|
||
pillarFocus: 0, // 전문성·신뢰
|
||
journeyFocus: 'awareness' as const,
|
||
description: '신규 유입 극대화 — YouTube Shorts, TikTok, Instagram Reels 집중',
|
||
},
|
||
{
|
||
label: 'Week 2: 관심 유도 & 교육 콘텐츠',
|
||
pillarFocus: 1, // 비포·애프터
|
||
journeyFocus: 'interest' as const,
|
||
description: '정보 탐색 — Long-form, 블로그 SEO, Carousel 집중',
|
||
},
|
||
{
|
||
label: 'Week 3: 신뢰 구축 & 소셜 증거',
|
||
pillarFocus: 2, // 환자 후기
|
||
journeyFocus: 'consideration' as const,
|
||
description: '비교 검토 — 강남언니 리뷰, Before/After, 의사 소개 집중',
|
||
},
|
||
{
|
||
label: 'Week 4: 전환 최적화 & CTA',
|
||
pillarFocus: 3, // 트렌드·교육
|
||
journeyFocus: 'conversion' as const,
|
||
description: '상담 예약 유도 — Facebook 광고, Instagram DM CTA, 프로모션',
|
||
},
|
||
];
|
||
|
||
// ─── Topic Generation (with keyword injection — Audit C5) ───
|
||
|
||
interface TopicPool {
|
||
topics: string[];
|
||
cursor: number;
|
||
}
|
||
|
||
function buildTopicPool(
|
||
pillars: ContentPillar[],
|
||
services: string[],
|
||
pillarIndex: number,
|
||
keywords?: { keyword: string; monthlySearches?: number }[],
|
||
): TopicPool {
|
||
const pillar = pillars[pillarIndex % pillars.length];
|
||
const svcList = services.length > 0 ? services : ['시술'];
|
||
|
||
const topics: string[] = [];
|
||
|
||
// Inject high-volume keywords as topics (Audit C5)
|
||
if (keywords?.length) {
|
||
const topKeywords = keywords
|
||
.sort((a, b) => (b.monthlySearches || 0) - (a.monthlySearches || 0))
|
||
.slice(0, 5)
|
||
.map(k => k.keyword);
|
||
for (const kw of topKeywords) {
|
||
topics.push(kw);
|
||
}
|
||
}
|
||
|
||
// Pillar-specific templates
|
||
switch (pillarIndex % 5) {
|
||
case 0: // 전문성·신뢰
|
||
for (const svc of svcList) {
|
||
topics.push(`${svc} 전문의 Q&A`);
|
||
topics.push(`${svc} 과정 완전정복`);
|
||
topics.push(`${svc} 수술실 CCTV 공개`);
|
||
}
|
||
topics.push('의료진 학회 발표·해외 연수');
|
||
topics.push('최신 장비 도입 소개');
|
||
topics.push('마취 안전 관리 시스템');
|
||
break;
|
||
case 1: // 비포·애프터
|
||
for (const svc of svcList) {
|
||
topics.push(`${svc} 전후 비교`);
|
||
topics.push(`${svc} Before/After 타임랩스`);
|
||
topics.push(`${svc} 3D 시뮬레이션 미리보기`);
|
||
}
|
||
topics.push('자연스러운 라인 완성');
|
||
topics.push('시간별 회복 과정 기록');
|
||
break;
|
||
case 2: // 환자 후기
|
||
for (const svc of svcList) {
|
||
topics.push(`${svc} 실제 환자 인터뷰`);
|
||
topics.push(`${svc} 회복 일기`);
|
||
}
|
||
topics.push('해외 환자 케이스');
|
||
topics.push('환자가 말하는: 왜 여기를 선택했나');
|
||
topics.push('리뷰 하이라이트 모음');
|
||
break;
|
||
case 3: // 트렌드·교육
|
||
for (const svc of svcList) {
|
||
topics.push(`${svc} 비용 완벽 가이드`);
|
||
topics.push(`${svc} FAQ 총정리`);
|
||
}
|
||
topics.push('2026 성형 트렌드: 자연주의');
|
||
topics.push('성형 전 필수 체크리스트');
|
||
topics.push('시술 비교: 이걸로 고민 끝');
|
||
break;
|
||
case 4: // 안전·케어 (5th Pillar — Audit H5)
|
||
for (const svc of svcList) {
|
||
topics.push(`${svc} 수술 후 관리 가이드`);
|
||
}
|
||
topics.push('24시간 집중 케어 시스템');
|
||
topics.push('전담 간호사 1:1 관리');
|
||
topics.push('리커버리 프로그램 소개');
|
||
topics.push('응급 상황 대응 프로토콜');
|
||
break;
|
||
}
|
||
|
||
// Add pillar example topics as fallback
|
||
for (const t of pillar?.exampleTopics || []) {
|
||
if (!topics.includes(t)) topics.push(t);
|
||
}
|
||
|
||
return { topics, cursor: 0 };
|
||
}
|
||
|
||
function nextTopic(pool: TopicPool): string {
|
||
const topic = pool.topics[pool.cursor % pool.topics.length];
|
||
pool.cursor++;
|
||
return topic;
|
||
}
|
||
|
||
// ─── Repurpose Topics from YouTube Videos ───
|
||
|
||
function buildRepurposeTopics(
|
||
videos: { title: string; views: number; type: 'Short' | 'Long' }[],
|
||
): string[] {
|
||
return videos
|
||
.sort((a, b) => b.views - a.views)
|
||
.slice(0, 6)
|
||
.map(v => v.title.replace(/[||\-–—].*$/, '').trim())
|
||
.filter(t => t.length > 2);
|
||
}
|
||
|
||
// ─── 강남언니 Special Topics ───
|
||
|
||
function buildGangnamUnniTopics(
|
||
services: string[],
|
||
data?: { rating?: number; reviews?: number; doctors?: number },
|
||
): string[] {
|
||
const topics: string[] = [];
|
||
const svcList = services.length > 0 ? services : ['시술'];
|
||
|
||
// Review management topics
|
||
topics.push('신규 리뷰 감사 응대');
|
||
topics.push('부정 리뷰 전문 응대');
|
||
|
||
// Profile optimization
|
||
for (const svc of svcList.slice(0, 3)) {
|
||
topics.push(`${svc} 시술 정보 업데이트`);
|
||
}
|
||
topics.push('의사 프로필 사진·경력 갱신');
|
||
topics.push('대표 시술 가격 재검토');
|
||
|
||
// Data-driven topics
|
||
if (data?.rating && data.rating < 9.0) {
|
||
topics.push('평점 개선 액션 플랜');
|
||
}
|
||
if (data?.reviews && data.reviews < 500) {
|
||
topics.push('리뷰 수 증가 캠페인');
|
||
}
|
||
|
||
// Events
|
||
for (const svc of svcList.slice(0, 2)) {
|
||
topics.push(`${svc} 시즌 이벤트`);
|
||
}
|
||
|
||
return topics;
|
||
}
|
||
|
||
// ─── Main Engine ───
|
||
|
||
export function generateContentPlan(input: ContentDirectorInput): ContentDirectorOutput {
|
||
const { channels, pillars, services, youtubeVideos, clinicName, keywords, gangnamUnniData } = input;
|
||
|
||
// 1. Determine active format slots based on available channels
|
||
const activeSlots: FormatSlot[] = [];
|
||
const channelIds = new Set(channels.map(c => c.channelId.toLowerCase()));
|
||
|
||
if (channelIds.has('youtube')) activeSlots.push(...YOUTUBE_SLOTS);
|
||
if (channelIds.has('instagram')) activeSlots.push(...INSTAGRAM_SLOTS);
|
||
if (channelIds.has('gangnamunni') || channelIds.has('gangnamUnni')) activeSlots.push(...GANGNAMUNNI_SLOTS);
|
||
if (channelIds.has('tiktok')) activeSlots.push(...TIKTOK_SLOTS);
|
||
if (channelIds.has('naverblog') || channelIds.has('naver_blog')) activeSlots.push(...NAVER_SLOTS);
|
||
if (channelIds.has('facebook')) activeSlots.push(...FACEBOOK_SLOTS);
|
||
|
||
// Fallback: if no channels matched, use core 3 channels
|
||
if (activeSlots.length === 0) {
|
||
activeSlots.push(...YOUTUBE_SLOTS, ...INSTAGRAM_SLOTS, ...NAVER_SLOTS);
|
||
}
|
||
|
||
// 2. Build repurpose topics from existing YouTube videos
|
||
const repurposeTopics = youtubeVideos ? buildRepurposeTopics(youtubeVideos) : [];
|
||
|
||
// 3. Build 강남언니 special topic pool
|
||
const guTopics = buildGangnamUnniTopics(services, gangnamUnniData);
|
||
let guCursor = 0;
|
||
|
||
// 4. Generate 4 weeks of content
|
||
const weeks: CalendarWeek[] = [];
|
||
|
||
for (let weekIdx = 0; weekIdx < 4; weekIdx++) {
|
||
const theme = WEEK_THEMES[weekIdx];
|
||
|
||
// Build topic pools focused on this week's pillar + 5th pillar rotation
|
||
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus, keywords);
|
||
const secondaryPool = buildTopicPool(pillars, services, (theme.pillarFocus + 1) % 5, keywords);
|
||
// 5th pillar (안전·케어) injected every other week
|
||
const carePool = weekIdx % 2 === 1
|
||
? buildTopicPool(pillars, services, 4, keywords)
|
||
: null;
|
||
|
||
const entries: CalendarEntry[] = [];
|
||
|
||
// Week 1 special: brand setup tasks
|
||
if (weekIdx === 0) {
|
||
entries.push({
|
||
dayOfWeek: 0,
|
||
channel: clinicName,
|
||
channelIcon: 'globe',
|
||
contentType: 'social',
|
||
title: '전 채널 프로필/브랜드 일관성 정비',
|
||
description: '프로필 사진, 배너, 바이오를 모든 채널에서 통일',
|
||
pillar: '전문성 · 신뢰',
|
||
status: 'draft',
|
||
});
|
||
}
|
||
|
||
// Fill format slots for this week
|
||
for (const slot of activeSlots) {
|
||
// Use care pool occasionally
|
||
const pool = carePool && entries.length % 5 === 0
|
||
? carePool
|
||
: entries.length % 2 === 0 ? primaryPool : secondaryPool;
|
||
|
||
for (let i = 0; i < slot.perWeek; i++) {
|
||
let topic: string;
|
||
|
||
// 강남언니: use dedicated topic pool
|
||
if (slot.channel === '강남언니') {
|
||
topic = guTopics[guCursor % guTopics.length];
|
||
guCursor++;
|
||
}
|
||
// Repurpose popular YouTube content for Shorts/TikTok in weeks 2-4
|
||
else if (weekIdx >= 1 && repurposeTopics.length > 0 && i === 0 &&
|
||
(slot.format === 'Shorts' || slot.format === '숏폼')) {
|
||
const rIdx = (weekIdx - 1 + i) % repurposeTopics.length;
|
||
topic = repurposeTopics[rIdx];
|
||
} else {
|
||
topic = nextTopic(pool);
|
||
}
|
||
|
||
const dayOfWeek = slot.preferredDays[i % slot.preferredDays.length];
|
||
|
||
entries.push({
|
||
dayOfWeek,
|
||
channel: slot.channel,
|
||
channelIcon: slot.channelIcon,
|
||
contentType: slot.contentType,
|
||
title: slot.titleTemplate(topic, i),
|
||
pillar: pillars[theme.pillarFocus % pillars.length]?.title,
|
||
status: 'draft',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Week 3 special: 강남언니 리뷰 집중 (소셜 증거 강화)
|
||
if (weekIdx === 2 && !channelIds.has('gangnamunni') && !channelIds.has('gangnamUnni')) {
|
||
entries.push({
|
||
dayOfWeek: 3,
|
||
channel: '강남언니',
|
||
channelIcon: 'star',
|
||
contentType: 'social',
|
||
title: '강남언니 프로필 개설/최적화',
|
||
description: '아직 강남언니 계정이 없다면 개설, 있다면 프로필 최적화',
|
||
pillar: '환자 후기 · 리뷰',
|
||
status: 'draft',
|
||
});
|
||
}
|
||
|
||
// Week 4 special: conversion-focused entries
|
||
if (weekIdx === 3) {
|
||
entries.push({
|
||
dayOfWeek: 5,
|
||
channel: 'Instagram',
|
||
channelIcon: 'instagram',
|
||
contentType: 'social',
|
||
title: 'Stories: 상담 예약 CTA + 카카오톡 링크',
|
||
description: 'DM→카카오톡→전화 전환 퍼널 활성화',
|
||
pillar: '트렌드 · 교육',
|
||
status: 'draft',
|
||
});
|
||
entries.push({
|
||
dayOfWeek: 6,
|
||
channel: 'Facebook',
|
||
channelIcon: 'facebook',
|
||
contentType: 'ad',
|
||
title: '광고: 리타겟팅 — 웹사이트 방문자 재유입',
|
||
description: '최근 30일 웹사이트 방문자 대상 리타겟팅 광고',
|
||
pillar: '트렌드 · 교육',
|
||
status: 'draft',
|
||
});
|
||
}
|
||
|
||
// Sort entries by day of week
|
||
entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
|
||
|
||
weeks.push({
|
||
weekNumber: weekIdx + 1,
|
||
label: theme.label,
|
||
entries,
|
||
});
|
||
}
|
||
|
||
// 5. Calculate monthly summary
|
||
const allEntries = weeks.flatMap(w => w.entries);
|
||
const monthlySummary: ContentCountSummary[] = [
|
||
{
|
||
type: 'video',
|
||
label: '영상',
|
||
count: allEntries.filter(e => e.contentType === 'video').length,
|
||
color: '#6C5CE7',
|
||
},
|
||
{
|
||
type: 'blog',
|
||
label: '블로그',
|
||
count: allEntries.filter(e => e.contentType === 'blog').length,
|
||
color: '#00B894',
|
||
},
|
||
{
|
||
type: 'social',
|
||
label: '소셜',
|
||
count: allEntries.filter(e => e.contentType === 'social').length,
|
||
color: '#E17055',
|
||
},
|
||
{
|
||
type: 'ad',
|
||
label: '광고',
|
||
count: allEntries.filter(e => e.contentType === 'ad').length,
|
||
color: '#FDCB6E',
|
||
},
|
||
];
|
||
|
||
return { weeks, monthlySummary };
|
||
}
|