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
parent
a7d8aeeddc
commit
e32b8766de
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
* 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 recommendations = report.recommendations as Record<string, unknown>[] | undefined;
|
||||
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 pillars = buildContentPillars(recommendations, services);
|
||||
|
|
@ -168,13 +345,13 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
|
|||
id: row.id,
|
||||
reportId: row.id,
|
||||
clinicName: (clinicInfo?.name as string) || row.clinic_name || '',
|
||||
clinicNameEn: '',
|
||||
clinicNameEn: (clinicInfo?.nameEn as string) || '',
|
||||
createdAt: row.created_at,
|
||||
targetUrl: row.url,
|
||||
|
||||
brandGuide: {
|
||||
colors: [],
|
||||
fonts: [],
|
||||
colors: buildBrandColors(branding),
|
||||
fonts: buildBrandFonts(branding),
|
||||
logoRules: [],
|
||||
toneOfVoice: {
|
||||
personality: ['전문적', '친근한', '신뢰할 수 있는'],
|
||||
|
|
@ -182,8 +359,8 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
|
|||
doExamples: ['정확한 의학 용어 사용', '환자 성공 사례 공유', '전문의 인사이트 제공'],
|
||||
dontExamples: ['과장된 효과 주장', '비교 광고', '의학적 보장 표현'],
|
||||
},
|
||||
channelBranding: [],
|
||||
brandInconsistencies: [],
|
||||
channelBranding: buildChannelBrandingFromEnrichment(enrichment),
|
||||
brandInconsistencies: buildBrandInconsistencies(enrichment, (clinicInfo?.name as string) || row.clinic_name || ''),
|
||||
},
|
||||
|
||||
channelStrategies,
|
||||
|
|
@ -214,9 +391,6 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
|
|||
|
||||
calendar,
|
||||
|
||||
assetCollection: {
|
||||
assets: [],
|
||||
youtubeRepurpose: [],
|
||||
},
|
||||
assetCollection: buildAssets(enrichment),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo }
|
|||
interface ApiReport {
|
||||
clinicInfo?: {
|
||||
name?: string;
|
||||
nameEn?: string;
|
||||
established?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
services?: string[];
|
||||
doctors?: { name: string; specialty: string }[];
|
||||
};
|
||||
newChannelProposals?: { channel?: string; priority?: string; rationale?: string }[];
|
||||
executiveSummary?: string;
|
||||
overallScore?: number;
|
||||
channelAnalysis?: Record<string, {
|
||||
|
|
@ -24,6 +27,7 @@ interface ApiReport {
|
|||
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 }[];
|
||||
|
|
@ -117,7 +121,20 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
|||
// Extract issues from channel analysis
|
||||
if (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({
|
||||
category: channel,
|
||||
detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`,
|
||||
|
|
@ -169,9 +186,9 @@ export function transformApiReport(
|
|||
|
||||
clinicSnapshot: {
|
||||
name: clinic.name || metadata.clinicName || '',
|
||||
nameEn: '',
|
||||
established: '',
|
||||
yearsInBusiness: 0,
|
||||
nameEn: clinic.nameEn || '',
|
||||
established: clinic.established || '',
|
||||
yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0,
|
||||
staffCount: 0,
|
||||
leadDoctor: {
|
||||
name: doctor?.name || '',
|
||||
|
|
@ -303,7 +320,13 @@ export function transformApiReport(
|
|||
asIs: issue,
|
||||
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 => ({
|
||||
|
|
@ -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.
|
||||
* Returns a new object — does not mutate the original.
|
||||
|
|
@ -590,13 +707,21 @@ export function mergeEnrichment(
|
|||
const gu = enrichment.gangnamUnni;
|
||||
|
||||
// Update clinic snapshot with real gangnamUnni data
|
||||
if (gu.rating) {
|
||||
merged.clinicSnapshot = {
|
||||
...merged.clinicSnapshot,
|
||||
overallRating: gu.rating,
|
||||
totalReviews: gu.totalReviews ?? merged.clinicSnapshot.totalReviews,
|
||||
certifications: gu.badges?.length ? gu.badges : merged.clinicSnapshot.certifications,
|
||||
};
|
||||
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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
|||
|
||||
{
|
||||
"clinicInfo": {
|
||||
"name": "병원명",
|
||||
"name": "병원명 (한국어)",
|
||||
"nameEn": "영문 병원명",
|
||||
"established": "개원년도 (예: 2005)",
|
||||
"address": "주소",
|
||||
"phone": "전화번호",
|
||||
"services": ["시술1", "시술2"],
|
||||
|
|
@ -96,20 +98,23 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
|||
"socialMedia": {
|
||||
"instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"],
|
||||
"youtube": "YouTube 채널 핸들 또는 URL",
|
||||
"facebook": "Facebook 페이지명",
|
||||
"facebook": "Facebook 페이지명 또는 URL",
|
||||
"naverBlog": "네이버 블로그 ID"
|
||||
}
|
||||
},
|
||||
"executiveSummary": "경영진 요약 (3-5문장)",
|
||||
"overallScore": 0-100,
|
||||
"channelAnalysis": {
|
||||
"naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항" },
|
||||
"instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항" },
|
||||
"youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항" },
|
||||
"naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" },
|
||||
"gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "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": "주요 전환 유도 요소" }
|
||||
"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": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "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": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "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": "주요 전환 유도 요소", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }
|
||||
},
|
||||
"newChannelProposals": [
|
||||
{ "channel": "제안 채널명", "priority": "P0|P1|P2", "rationale": "채널 개설 근거" }
|
||||
],
|
||||
"competitors": [
|
||||
{ "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] }
|
||||
],
|
||||
|
|
@ -130,7 +135,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
|||
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
|
||||
],
|
||||
"kpiTargets": [
|
||||
{ "metric": "지표명 (종합 점수, Instagram 팔로워, YouTube 구독자, 네이버 블로그 방문자, 월간 상담 문의 등)", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
|
||||
{ "metric": "외부 측정 가능한 지표만 포함 (종합 점수, Instagram 팔로워, YouTube 구독자/조회수, 네이버 블로그 검색 노출 수, 강남언니 리뷰 수, Google Maps 평점 등). 병원 내부에서만 알 수 있는 지표(상담 문의, 매출, 예약 수 등)는 절대 포함하지 마세요.", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
|
||||
],
|
||||
"recommendations": [
|
||||
{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" }
|
||||
|
|
|
|||
|
|
@ -122,9 +122,48 @@ Deno.serve(async (req) => {
|
|||
|
||||
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
|
||||
const result = {
|
||||
clinic: scrapeData.data?.json || {},
|
||||
branding: brandingData,
|
||||
siteLinks: scrapeData.data?.links || [],
|
||||
siteMap: mapData.success ? mapData.links || [] : [],
|
||||
reviews: searchData.data || [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue