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

523 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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 };
}