feat: prototype gap closure — enrichment diagnosis + brand extraction + plan assets

Phase 1: Data Pipeline Fixes
- Plan page: connect enrichment data for Asset Collection + YouTube Repurpose
- mergeEnrichment: generate 15-20 data-driven diagnosis items from enrichment
  (YouTube Shorts check, IG engagement, FB activity, 강남언니 ratings, GMaps)
- ClinicSnapshot: fill staffCount, nearestStation, certifications from enrichment

Phase 2: AI + Brand Enhancement
- AI prompt: per-channel diagnosis[] array (5-7 items), established, nameEn, newChannelProposals
- scrape-website: Firecrawl branding extraction (colors, fonts, logo, tagline)
- transformPlan: BrandGuide colors/fonts from scraped branding data
- transformPlan: cross-channel brand consistency analysis (name, phone mismatches)
- transformPlan: channel branding rules from enrichment (YT, IG, FB profiles)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-03 17:09:15 +09:00
parent a7d8aeeddc
commit e32b8766de
4 changed files with 380 additions and 31 deletions

View File

@ -1,4 +1,5 @@
import type { MarketingPlan, ChannelStrategyCard, ContentPillar, CalendarWeek, CalendarEntry, ContentCountSummary } from '../types/plan'; import type { MarketingPlan, ChannelStrategyCard, ContentPillar, CalendarWeek, CalendarEntry, ContentCountSummary, AssetCard, YouTubeRepurposeItem } from '../types/plan';
import type { EnrichmentData } from './transformReport';
/** /**
* Raw report data from Supabase marketing_reports table. * Raw report data from Supabase marketing_reports table.
@ -148,6 +149,179 @@ function buildCalendar(channels: ChannelStrategyCard[]): {
}; };
} }
function buildAssets(enrichment: EnrichmentData | undefined): { assets: AssetCard[]; youtubeRepurpose: YouTubeRepurposeItem[] } {
if (!enrichment) return { assets: [], youtubeRepurpose: [] };
const assets: AssetCard[] = [];
let assetIdx = 0;
// YouTube videos → video assets
if (enrichment.youtube?.videos) {
for (const v of enrichment.youtube.videos.slice(0, 5)) {
assets.push({
id: `yt-${assetIdx++}`,
source: 'youtube',
sourceLabel: 'YouTube',
type: 'video',
title: v.title || '영상',
description: `조회수 ${(v.views || 0).toLocaleString()} · 좋아요 ${(v.likes || 0).toLocaleString()}`,
repurposingSuggestions: ['Shorts 추출', '블로그 스크립트', '카드뉴스'],
status: 'collected',
});
}
}
// Instagram posts → photo assets
const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
for (const ig of igAccounts) {
if (ig.latestPosts) {
for (const p of (ig.latestPosts as { type?: string; likes?: number; caption?: string }[]).slice(0, 3)) {
assets.push({
id: `ig-${assetIdx++}`,
source: 'social',
sourceLabel: `Instagram @${ig.username || ''}`,
type: 'photo',
title: (p.caption || '').slice(0, 60) || 'Instagram 게시물',
description: `좋아요 ${(p.likes || 0).toLocaleString()}`,
repurposingSuggestions: ['피드 리포스트', '스토리 하이라이트'],
status: 'collected',
});
}
}
}
// Naver blog posts → text assets
if (enrichment.naverBlog?.posts) {
for (const post of enrichment.naverBlog.posts.slice(0, 3)) {
assets.push({
id: `nb-${assetIdx++}`,
source: 'blog',
sourceLabel: '네이버 블로그',
type: 'text',
title: post.title || '블로그 포스트',
description: post.description || '',
repurposingSuggestions: ['SNS 카드뉴스', '영상 스크립트'],
status: 'collected',
});
}
}
// YouTube repurpose items
const youtubeRepurpose: YouTubeRepurposeItem[] = (enrichment.youtube?.videos || [])
.slice(0, 5)
.map(v => ({
title: v.title || '',
views: v.views || 0,
type: ((v.duration && parseInt(v.duration) < 60) ? 'Short' : 'Long') as 'Short' | 'Long',
repurposeAs: ['Shorts 3개 추출', '블로그 포스트 변환', '카드뉴스 4장', '카카오톡 CTA'],
}));
return { assets, youtubeRepurpose };
}
function buildChannelBrandingFromEnrichment(enrichment: EnrichmentData | undefined) {
if (!enrichment) return [];
const rules: { channel: string; icon: string; profilePhoto: string; bannerSpec: string; bioTemplate: string; currentStatus: 'correct' | 'incorrect' | 'missing' }[] = [];
if (enrichment.youtube) {
rules.push({
channel: 'YouTube',
icon: 'youtube',
profilePhoto: enrichment.youtube.thumbnailUrl || '',
bannerSpec: '2560×1440px 배너',
bioTemplate: enrichment.youtube.description?.slice(0, 200) || '',
currentStatus: enrichment.youtube.description ? 'correct' : 'missing',
});
}
const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
for (const ig of igAccounts) {
rules.push({
channel: `Instagram @${ig.username || ''}`,
icon: 'instagram',
profilePhoto: '',
bannerSpec: 'N/A (하이라이트 커버)',
bioTemplate: ig.bio || '',
currentStatus: ig.bio ? 'correct' : 'missing',
});
}
if (enrichment.facebook) {
rules.push({
channel: 'Facebook',
icon: 'facebook',
profilePhoto: enrichment.facebook.profilePictureUrl || '',
bannerSpec: '820×312px 커버 사진',
bioTemplate: enrichment.facebook.intro || '',
currentStatus: enrichment.facebook.intro ? 'correct' : 'missing',
});
}
return rules;
}
function buildBrandInconsistencies(enrichment: EnrichmentData | undefined, clinicName: string): { field: string; values: { channel: string; value: string; isCorrect: boolean }[]; impact: string; recommendation: string }[] {
if (!enrichment) return [];
const items: { field: string; values: { channel: string; value: string; isCorrect: boolean }[]; impact: string; recommendation: string }[] = [];
// Collect names across channels
const names: { channel: string; value: string }[] = [];
if (clinicName) names.push({ channel: '웹사이트', value: clinicName });
if (enrichment.youtube?.channelName) names.push({ channel: 'YouTube', value: enrichment.youtube.channelName });
const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
for (const ig of igAccounts) {
if (ig.username) names.push({ channel: `Instagram @${ig.username}`, value: ig.username });
}
if (enrichment.facebook?.pageName) names.push({ channel: 'Facebook', value: enrichment.facebook.pageName });
if (enrichment.gangnamUnni?.name) names.push({ channel: '강남언니', value: enrichment.gangnamUnni.name });
if (names.length >= 2) {
const websiteName = names[0].value;
items.push({
field: '병원명',
values: names.map(n => ({ channel: n.channel, value: n.value, isCorrect: n.value.toLowerCase().includes(websiteName.toLowerCase().slice(0, 4)) })),
impact: '채널마다 다른 이름은 브랜드 인지도를 분산시킵니다',
recommendation: '모든 채널에서 동일한 공식 병원명을 사용하세요',
});
}
// Collect phone numbers
const phones: { channel: string; value: string }[] = [];
if (enrichment.googleMaps?.phone) phones.push({ channel: 'Google Maps', value: enrichment.googleMaps.phone as string });
if (enrichment.naverPlace?.telephone) phones.push({ channel: '네이버 플레이스', value: enrichment.naverPlace.telephone });
if (enrichment.facebook?.phone) phones.push({ channel: 'Facebook', value: enrichment.facebook.phone as string });
if (phones.length >= 2) {
const ref = phones[0].value.replace(/[^0-9]/g, '');
items.push({
field: '연락처',
values: phones.map(p => ({ channel: p.channel, value: p.value, isCorrect: p.value.replace(/[^0-9]/g, '') === ref })),
impact: '다른 전화번호는 고객 혼란을 유발합니다',
recommendation: '모든 플랫폼에 동일한 대표 전화번호를 등록하세요',
});
}
return items;
}
function buildBrandColors(branding: Record<string, unknown> | undefined): { name: string; hex: string; usage: string }[] {
if (!branding) return [];
const colors: { name: string; hex: string; usage: string }[] = [];
if (branding.primaryColor) colors.push({ name: 'Primary', hex: branding.primaryColor as string, usage: '메인 브랜드 색상' });
if (branding.accentColor) colors.push({ name: 'Accent', hex: branding.accentColor as string, usage: '강조 색상' });
if (branding.backgroundColor) colors.push({ name: 'Background', hex: branding.backgroundColor as string, usage: '배경 색상' });
if (branding.textColor) colors.push({ name: 'Text', hex: branding.textColor as string, usage: '본문 텍스트' });
return colors;
}
function buildBrandFonts(branding: Record<string, unknown> | undefined): { family: string; weight: string; usage: string; sampleText: string }[] {
if (!branding) return [];
const fonts: { family: string; weight: string; usage: string; sampleText: string }[] = [];
if (branding.headingFont) fonts.push({ family: branding.headingFont as string, weight: 'Bold', usage: '제목/헤딩', sampleText: '안전이 예술이 되는 곳' });
if (branding.bodyFont) fonts.push({ family: branding.bodyFont as string, weight: 'Regular', usage: '본문 텍스트', sampleText: '프리미엄 의료 서비스를 경험하세요' });
return fonts;
}
/** /**
* Transform a raw Supabase report row into a MarketingPlan. * Transform a raw Supabase report row into a MarketingPlan.
* Uses report data (channel analysis, recommendations, services) * Uses report data (channel analysis, recommendations, services)
@ -159,6 +333,9 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
const channelAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined; const channelAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
const recommendations = report.recommendations as Record<string, unknown>[] | undefined; const recommendations = report.recommendations as Record<string, unknown>[] | undefined;
const services = (clinicInfo?.services as string[]) || []; const services = (clinicInfo?.services as string[]) || [];
const enrichment = report.channelEnrichment as EnrichmentData | undefined;
const scrapeData = row.scrape_data as Record<string, unknown> | undefined;
const branding = scrapeData?.branding as Record<string, unknown> | undefined;
const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations); const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations);
const pillars = buildContentPillars(recommendations, services); const pillars = buildContentPillars(recommendations, services);
@ -168,13 +345,13 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
id: row.id, id: row.id,
reportId: row.id, reportId: row.id,
clinicName: (clinicInfo?.name as string) || row.clinic_name || '', clinicName: (clinicInfo?.name as string) || row.clinic_name || '',
clinicNameEn: '', clinicNameEn: (clinicInfo?.nameEn as string) || '',
createdAt: row.created_at, createdAt: row.created_at,
targetUrl: row.url, targetUrl: row.url,
brandGuide: { brandGuide: {
colors: [], colors: buildBrandColors(branding),
fonts: [], fonts: buildBrandFonts(branding),
logoRules: [], logoRules: [],
toneOfVoice: { toneOfVoice: {
personality: ['전문적', '친근한', '신뢰할 수 있는'], personality: ['전문적', '친근한', '신뢰할 수 있는'],
@ -182,8 +359,8 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
doExamples: ['정확한 의학 용어 사용', '환자 성공 사례 공유', '전문의 인사이트 제공'], doExamples: ['정확한 의학 용어 사용', '환자 성공 사례 공유', '전문의 인사이트 제공'],
dontExamples: ['과장된 효과 주장', '비교 광고', '의학적 보장 표현'], dontExamples: ['과장된 효과 주장', '비교 광고', '의학적 보장 표현'],
}, },
channelBranding: [], channelBranding: buildChannelBrandingFromEnrichment(enrichment),
brandInconsistencies: [], brandInconsistencies: buildBrandInconsistencies(enrichment, (clinicInfo?.name as string) || row.clinic_name || ''),
}, },
channelStrategies, channelStrategies,
@ -214,9 +391,6 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
calendar, calendar,
assetCollection: { assetCollection: buildAssets(enrichment),
assets: [],
youtubeRepurpose: [],
},
}; };
} }

View File

@ -7,11 +7,14 @@ import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo }
interface ApiReport { interface ApiReport {
clinicInfo?: { clinicInfo?: {
name?: string; name?: string;
nameEn?: string;
established?: string;
address?: string; address?: string;
phone?: string; phone?: string;
services?: string[]; services?: string[];
doctors?: { name: string; specialty: string }[]; doctors?: { name: string; specialty: string }[];
}; };
newChannelProposals?: { channel?: string; priority?: string; rationale?: string }[];
executiveSummary?: string; executiveSummary?: string;
overallScore?: number; overallScore?: number;
channelAnalysis?: Record<string, { channelAnalysis?: Record<string, {
@ -24,6 +27,7 @@ interface ApiReport {
reviews?: number; reviews?: number;
issues?: string[]; issues?: string[];
recommendation?: string; recommendation?: string;
diagnosis?: { issue?: string; severity?: string; recommendation?: string }[];
trackingPixels?: { name: string; installed: boolean }[]; trackingPixels?: { name: string; installed: boolean }[];
snsLinksOnSite?: boolean; snsLinksOnSite?: boolean;
additionalDomains?: { domain: string; purpose: string }[]; additionalDomains?: { domain: string; purpose: string }[];
@ -117,7 +121,20 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
// Extract issues from channel analysis // Extract issues from channel analysis
if (report.channelAnalysis) { if (report.channelAnalysis) {
for (const [channel, ch] of Object.entries(report.channelAnalysis)) { for (const [channel, ch] of Object.entries(report.channelAnalysis)) {
if (ch.status === 'inactive' || ch.status === 'weak') { // AI-generated per-channel diagnosis array (new format)
if (ch.diagnosis) {
for (const d of ch.diagnosis) {
if (d.issue) {
items.push({
category: CHANNEL_ICONS[channel] ? channel : channel,
detail: d.recommendation ? `${d.issue}${d.recommendation}` : d.issue,
severity: (d.severity as Severity) || 'warning',
});
}
}
}
// Fallback: single recommendation (old format)
else if (ch.status === 'inactive' || ch.status === 'weak') {
items.push({ items.push({
category: channel, category: channel,
detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`, detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`,
@ -169,9 +186,9 @@ export function transformApiReport(
clinicSnapshot: { clinicSnapshot: {
name: clinic.name || metadata.clinicName || '', name: clinic.name || metadata.clinicName || '',
nameEn: '', nameEn: clinic.nameEn || '',
established: '', established: clinic.established || '',
yearsInBusiness: 0, yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0,
staffCount: 0, staffCount: 0,
leadDoctor: { leadDoctor: {
name: doctor?.name || '', name: doctor?.name || '',
@ -303,7 +320,13 @@ export function transformApiReport(
asIs: issue, asIs: issue,
toBe: '개선 필요', toBe: '개선 필요',
})), })),
newChannelProposals: [], 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 || '',
})),
}, },
roadmap: [1, 2, 3].map(month => ({ roadmap: [1, 2, 3].map(month => ({
@ -455,6 +478,100 @@ export interface EnrichmentData {
}; };
} }
/**
* 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. * Merge enrichment data into an existing MarketingReport.
* Returns a new object does not mutate the original. * Returns a new object does not mutate the original.
@ -590,13 +707,21 @@ export function mergeEnrichment(
const gu = enrichment.gangnamUnni; const gu = enrichment.gangnamUnni;
// Update clinic snapshot with real gangnamUnni data // Update clinic snapshot with real gangnamUnni data
if (gu.rating) {
merged.clinicSnapshot = { merged.clinicSnapshot = {
...merged.clinicSnapshot, ...merged.clinicSnapshot,
overallRating: gu.rating, overallRating: gu.rating ?? merged.clinicSnapshot.overallRating,
totalReviews: gu.totalReviews ?? merged.clinicSnapshot.totalReviews, totalReviews: gu.totalReviews ?? merged.clinicSnapshot.totalReviews,
certifications: gu.badges?.length ? gu.badges : merged.clinicSnapshot.certifications, 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 // Update lead doctor with gangnamUnni doctor data
@ -694,5 +819,11 @@ export function mergeEnrichment(
} }
} }
// Generate data-driven diagnosis from enrichment data
const enrichDiagnosis = generateEnrichmentDiagnosis(enrichment);
if (enrichDiagnosis.length > 0) {
merged.problemDiagnosis = [...merged.problemDiagnosis, ...enrichDiagnosis];
}
return merged; return merged;
} }

View File

@ -88,7 +88,9 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
{ {
"clinicInfo": { "clinicInfo": {
"name": "병원명", "name": "병원명 (한국어)",
"nameEn": "영문 병원명",
"established": "개원년도 (예: 2005)",
"address": "주소", "address": "주소",
"phone": "전화번호", "phone": "전화번호",
"services": ["시술1", "시술2"], "services": ["시술1", "시술2"],
@ -96,20 +98,23 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
"socialMedia": { "socialMedia": {
"instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"], "instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"],
"youtube": "YouTube 채널 핸들 또는 URL", "youtube": "YouTube 채널 핸들 또는 URL",
"facebook": "Facebook 페이지명", "facebook": "Facebook 페이지명 또는 URL",
"naverBlog": "네이버 블로그 ID" "naverBlog": "네이버 블로그 ID"
} }
}, },
"executiveSummary": "경영진 요약 (3-5문장)", "executiveSummary": "경영진 요약 (3-5문장)",
"overallScore": 0-100, "overallScore": 0-100,
"channelAnalysis": { "channelAnalysis": {
"naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항" }, "naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
"instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항" }, "instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
"youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항" }, "youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
"naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" }, "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
"gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" }, "gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
"website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "Google Analytics|Facebook Pixel|Naver Analytics|etc", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [{"domain": "example.com", "purpose": "용도"}], "mainCTA": "주요 전환 유도 요소" } "website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "Google Analytics|Facebook Pixel|Naver Analytics|etc", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [{"domain": "example.com", "purpose": "용도"}], "mainCTA": "주요 전환 유도 요소", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }
}, },
"newChannelProposals": [
{ "channel": "제안 채널명", "priority": "P0|P1|P2", "rationale": "채널 개설 근거" }
],
"competitors": [ "competitors": [
{ "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] } { "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] }
], ],
@ -130,7 +135,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" } { "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
], ],
"kpiTargets": [ "kpiTargets": [
{ "metric": "지표명 (종합 점수, Instagram 팔로워, YouTube 구독자, 네이버 블로그 방문자, 월간 상담 문의 등)", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" } { "metric": "외부 측정 가능한 지표만 포함 (종합 점수, Instagram 팔로워, YouTube 구독자/조회수, 네이버 블로그 검색 노출 수, 강남언니 리뷰 수, Google Maps 평점 등). 병원 내부에서만 알 수 있는 지표(상담 문의, 매출, 예약 수 등)는 절대 포함하지 마세요.", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
], ],
"recommendations": [ "recommendations": [
{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" } { "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" }

View File

@ -122,9 +122,48 @@ Deno.serve(async (req) => {
const searchData = await searchResponse.json(); const searchData = await searchResponse.json();
// Step 4: Extract branding (colors, fonts, logos) from the website
let brandingData: Record<string, unknown> = {};
try {
const brandResponse = await fetch("https://api.firecrawl.dev/v1/scrape", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
},
body: JSON.stringify({
url,
formats: ["json"],
jsonOptions: {
prompt: "Extract brand identity from this website: primary brand colors (hex codes), accent colors, background colors, font families used (headings and body), logo image URLs (header logo, favicon), and main tagline/slogan",
schema: {
type: "object",
properties: {
primaryColor: { type: "string" },
accentColor: { type: "string" },
backgroundColor: { type: "string" },
textColor: { type: "string" },
headingFont: { type: "string" },
bodyFont: { type: "string" },
logoUrl: { type: "string" },
faviconUrl: { type: "string" },
tagline: { type: "string" },
},
},
},
waitFor: 3000,
}),
});
const brandResult = await brandResponse.json();
brandingData = brandResult.data?.json || {};
} catch {
// Branding extraction is optional — don't fail the whole scrape
}
// Combine all data // Combine all data
const result = { const result = {
clinic: scrapeData.data?.json || {}, clinic: scrapeData.data?.json || {},
branding: brandingData,
siteLinks: scrapeData.data?.links || [], siteLinks: scrapeData.data?.links || [],
siteMap: mapData.success ? mapData.links || [] : [], siteMap: mapData.success ? mapData.links || [] : [],
reviews: searchData.data || [], reviews: searchData.data || [],