feat: Pipeline V2 — 3-phase analysis with verified channel discovery
Restructured the entire analysis pipeline from AI-guessing social handles to deterministic 3-phase discovery + collection + generation. Phase 1 (discover-channels): 3-source channel discovery - Firecrawl scrape: extract social links from HTML - Perplexity search: find handles via web search - URL regex parsing: deterministic link extraction - Handle verification: HEAD requests + YouTube API - DB: creates row with verified_channels + scrape_data Phase 2 (collect-channel-data): 9 parallel data collectors - Instagram (Apify), YouTube (Data API v3), Facebook (Apify) - 강남언니 (Firecrawl), Naver Blog + Place (Naver API) - Google Maps (Apify), Market analysis (Perplexity 4x parallel) - DB: stores ALL raw data in channel_data column Phase 3 (generate-report): AI report from real data - Reads channel_data + analysis_data from DB - Builds channel summary with real metrics - AI generates report using only verified data - V1 backwards compatibility preserved (url-based flow) Supporting changes: - DB migration: status, verified_channels, channel_data columns - _shared/extractSocialLinks.ts: regex-based social link parser - _shared/verifyHandles.ts: multi-platform handle verifier - AnalysisLoadingPage: real 3-phase progress + channel panel - useReport: channel_data column support + V2 enrichment merge - 강남언니 rating: auto-correct 5→10 scale + search fallback - KPIDashboard: navigate() instead of <a href> - Loading text: 20-30초 → 1-2분 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>claude/bold-hawking
parent
da267fd744
commit
7557ef774c
|
|
@ -14,7 +14,7 @@ function formatNumber(n: number): string {
|
||||||
const infoFields = (data: ClinicSnapshotType) => [
|
const infoFields = (data: ClinicSnapshotType) => [
|
||||||
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
|
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
|
||||||
data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}명`, icon: Users } : null,
|
data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}명`, icon: Users } : null,
|
||||||
data.overallRating > 0 ? { label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star } : null,
|
data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null,
|
||||||
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
|
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
|
||||||
data.priceRange.min !== '-' ? { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe } : null,
|
data.priceRange.min !== '-' ? { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe } : null,
|
||||||
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
|
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { TrendingUp, ArrowUpRight, Download, Loader2 } from 'lucide-react';
|
import { TrendingUp, ArrowUpRight, Download, Loader2 } from 'lucide-react';
|
||||||
import { SectionWrapper } from './ui/SectionWrapper';
|
import { SectionWrapper } from './ui/SectionWrapper';
|
||||||
import { useExportPDF } from '../../hooks/useExportPDF';
|
import { useExportPDF } from '../../hooks/useExportPDF';
|
||||||
|
|
@ -30,6 +30,7 @@ function formatKpiValue(value: string): string {
|
||||||
|
|
||||||
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
|
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { exportPDF, isExporting } = useExportPDF();
|
const { exportPDF, isExporting } = useExportPDF();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -94,13 +95,13 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps)
|
||||||
INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.
|
INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<a
|
<button
|
||||||
href={`/plan/${id || 'live'}`}
|
onClick={() => navigate(`/plan/${id || 'live'}`)}
|
||||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
|
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
|
||||||
>
|
>
|
||||||
마케팅 기획
|
마케팅 기획
|
||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
|
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,17 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
state.report,
|
state.report,
|
||||||
state.metadata,
|
state.metadata,
|
||||||
);
|
);
|
||||||
|
// V2 pipeline: report already includes channelEnrichment from Phase 2
|
||||||
|
const enrichment = state.report.channelEnrichment as EnrichmentData | undefined;
|
||||||
|
if (enrichment) {
|
||||||
|
const merged = mergeEnrichment(transformed, enrichment);
|
||||||
|
setData(merged);
|
||||||
|
setIsEnriched(true);
|
||||||
|
} else {
|
||||||
setData(transformed);
|
setData(transformed);
|
||||||
setSocialHandles(state.metadata.socialHandles || null);
|
|
||||||
setIsEnriched(false);
|
setIsEnriched(false);
|
||||||
|
}
|
||||||
|
setSocialHandles(state.metadata.socialHandles || null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to parse report data');
|
setError(err instanceof Error ? err.message : 'Failed to parse report data');
|
||||||
|
|
@ -101,8 +109,13 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
}
|
}
|
||||||
setSocialHandles(handles as Record<string, string | null> | null);
|
setSocialHandles(handles as Record<string, string | null> | null);
|
||||||
|
|
||||||
// If channelEnrichment already exists in DB, merge it immediately
|
// V2: channel_data column (separate from report JSONB)
|
||||||
const enrichment = reportJson.channelEnrichment as EnrichmentData | undefined;
|
// V1 compat: channelEnrichment inside report JSONB
|
||||||
|
const enrichment =
|
||||||
|
(row.channel_data && Object.keys(row.channel_data).length > 0 ? row.channel_data : null) as EnrichmentData | null
|
||||||
|
|| (reportJson.channelEnrichment as EnrichmentData | undefined)
|
||||||
|
|| null;
|
||||||
|
|
||||||
if (enrichment) {
|
if (enrichment) {
|
||||||
const merged = mergeEnrichment(transformed, enrichment);
|
const merged = mergeEnrichment(transformed, enrichment);
|
||||||
setData(merged);
|
setData(merged);
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,67 @@ export async function scrapeWebsite(url: string, clinicName?: string) {
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Pipeline V2 API Functions ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Discover & verify social channels from website URL.
|
||||||
|
* Returns verified handles + reportId for subsequent phases.
|
||||||
|
*/
|
||||||
|
export async function discoverChannels(url: string, clinicName?: string) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/functions/v1/discover-channels`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url, clinicName }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Channel discovery failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Collect all channel data using verified handles.
|
||||||
|
* Reads verified_channels from DB, runs parallel API calls.
|
||||||
|
*/
|
||||||
|
export async function collectChannelData(reportId: string) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/functions/v1/collect-channel-data`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reportId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Data collection failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: Generate AI report using real collected data from DB.
|
||||||
|
*/
|
||||||
|
export async function generateReportV2(reportId: string) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${supabaseUrl}/functions/v1/generate-report`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reportId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Report generation failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -453,7 +453,11 @@ export function transformApiReport(
|
||||||
rating: 0,
|
rating: 0,
|
||||||
reviewCount: 0,
|
reviewCount: 0,
|
||||||
},
|
},
|
||||||
overallRating: r.channelAnalysis?.gangnamUnni?.rating ?? 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,
|
totalReviews: r.channelAnalysis?.gangnamUnni?.reviews ?? 0,
|
||||||
priceRange: { min: '-', max: '-', currency: '₩' },
|
priceRange: { min: '-', max: '-', currency: '₩' },
|
||||||
certifications: [],
|
certifications: [],
|
||||||
|
|
@ -529,8 +533,12 @@ export function transformApiReport(
|
||||||
}] : []),
|
}] : []),
|
||||||
...(r.channelAnalysis?.gangnamUnni ? [{
|
...(r.channelAnalysis?.gangnamUnni ? [{
|
||||||
name: '강남언니',
|
name: '강남언니',
|
||||||
status: (r.channelAnalysis.gangnamUnni.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive',
|
status: (r.channelAnalysis.gangnamUnni.status === 'active' || r.channelAnalysis.gangnamUnni.rating ? 'active' : 'inactive') as 'active' | 'inactive',
|
||||||
details: `평점: ${r.channelAnalysis.gangnamUnni.rating ?? '-'} / 리뷰: ${r.channelAnalysis.gangnamUnni.reviews ?? '-'}`,
|
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 ?? '-'}`;
|
||||||
|
})(),
|
||||||
}] : []),
|
}] : []),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,60 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Check, AlertCircle } from 'lucide-react';
|
import { Check, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
import { generateMarketingReport } from '../lib/supabase';
|
import { discoverChannels, collectChannelData, generateReportV2 } from '../lib/supabase';
|
||||||
|
|
||||||
const steps = [
|
/**
|
||||||
{ label: 'Scanning website...', key: 'scrape' },
|
* Pipeline V2: 3-phase analysis with real progress.
|
||||||
{ label: 'Analyzing social media presence...', key: 'social' },
|
* Phase 1: discover-channels → verify social handles
|
||||||
{ label: 'Researching competitors & keywords...', key: 'analyze' },
|
* Phase 2: collect-channel-data → gather all channel data + market analysis
|
||||||
{ label: 'Generating AI marketing report...', key: 'generate' },
|
* Phase 3: generate-report → AI report from real data
|
||||||
{ label: 'Finalizing report...', key: 'finalize' },
|
*/
|
||||||
|
|
||||||
|
interface VerifiedChannel {
|
||||||
|
handle: string;
|
||||||
|
verified: boolean;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedChannels {
|
||||||
|
instagram?: VerifiedChannel[];
|
||||||
|
youtube?: VerifiedChannel | null;
|
||||||
|
facebook?: VerifiedChannel | null;
|
||||||
|
naverBlog?: VerifiedChannel | null;
|
||||||
|
gangnamUnni?: VerifiedChannel | null;
|
||||||
|
tiktok?: VerifiedChannel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'discovering' | 'collecting' | 'generating' | 'complete';
|
||||||
|
|
||||||
|
const PHASE_STEPS = [
|
||||||
|
{ key: 'discovering' as Phase, label: '웹사이트 스캔 & 채널 발견 중...', labelDone: '채널 발견 완료' },
|
||||||
|
{ key: 'collecting' as Phase, label: '채널 데이터 수집 & 시장 분석 중...', labelDone: '데이터 수집 완료' },
|
||||||
|
{ key: 'generating' as Phase, label: 'AI 마케팅 리포트 생성 중...', labelDone: '리포트 생성 완료' },
|
||||||
|
{ key: 'complete' as Phase, label: '분석 완료!', labelDone: '분석 완료!' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
instagram: 'Instagram',
|
||||||
|
youtube: 'YouTube',
|
||||||
|
facebook: 'Facebook',
|
||||||
|
naverBlog: '네이버 블로그',
|
||||||
|
gangnamUnni: '강남언니',
|
||||||
|
tiktok: 'TikTok',
|
||||||
|
};
|
||||||
|
|
||||||
export default function AnalysisLoadingPage() {
|
export default function AnalysisLoadingPage() {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [phase, setPhase] = useState<Phase>('discovering');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [verifiedChannels, setVerifiedChannels] = useState<VerifiedChannels | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const url = (location.state as { url?: string })?.url;
|
const url = (location.state as { url?: string })?.url;
|
||||||
const hasStarted = useRef(false);
|
const hasStarted = useRef(false);
|
||||||
|
|
||||||
|
const phaseIndex = PHASE_STEPS.findIndex(s => s.key === phase);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasStarted.current) return;
|
if (hasStarted.current) return;
|
||||||
hasStarted.current = true;
|
hasStarted.current = true;
|
||||||
|
|
@ -29,46 +64,48 @@ export default function AnalysisLoadingPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAnalysis = async () => {
|
const runPipeline = async () => {
|
||||||
try {
|
try {
|
||||||
// Step 1: Scraping
|
// ─── Phase 1: Discover Channels ───
|
||||||
setCurrentStep(1);
|
setPhase('discovering');
|
||||||
|
|
||||||
// Simulate step progression while waiting for API
|
const discovery = await discoverChannels(url);
|
||||||
const stepTimer = setInterval(() => {
|
if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed');
|
||||||
setCurrentStep((prev) => (prev < steps.length - 1 ? prev + 1 : prev));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
const result = await generateMarketingReport(url);
|
setVerifiedChannels(discovery.verifiedChannels);
|
||||||
|
const reportId = discovery.reportId;
|
||||||
|
|
||||||
clearInterval(stepTimer);
|
// ─── Phase 2: Collect Channel Data ───
|
||||||
setCurrentStep(steps.length);
|
setPhase('collecting');
|
||||||
|
|
||||||
if (result.success && result.report) {
|
const collection = await collectChannelData(reportId);
|
||||||
const reportPath = result.reportId
|
if (!collection.success) throw new Error(collection.error || 'Data collection failed');
|
||||||
? `/report/${result.reportId}`
|
|
||||||
: '/report/live';
|
// ─── Phase 3: Generate Report ───
|
||||||
|
setPhase('generating');
|
||||||
|
|
||||||
|
const result = await generateReportV2(reportId);
|
||||||
|
if (!result.success) throw new Error(result.error || 'Report generation failed');
|
||||||
|
|
||||||
|
// ─── Complete ───
|
||||||
|
setPhase('complete');
|
||||||
|
|
||||||
// Brief delay to show completion
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(reportPath, {
|
navigate(`/report/${reportId}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
state: {
|
state: {
|
||||||
report: result.report,
|
report: result.report,
|
||||||
metadata: result.metadata,
|
metadata: result.metadata,
|
||||||
reportId: result.reportId,
|
reportId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 800);
|
}, 1000);
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Report generation failed');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runAnalysis();
|
runPipeline();
|
||||||
}, [url, navigate]);
|
}, [url, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -96,7 +133,7 @@ export default function AnalysisLoadingPage() {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.1 }}
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
|
className="text-purple-300/80 text-sm font-mono mb-10 truncate max-w-full"
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
@ -114,26 +151,28 @@ export default function AnalysisLoadingPage() {
|
||||||
onClick={() => navigate('/', { replace: true })}
|
onClick={() => navigate('/', { replace: true })}
|
||||||
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
||||||
>
|
>
|
||||||
Try Again
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="w-full space-y-5 mb-14">
|
{/* Pipeline steps */}
|
||||||
{steps.map((step, index) => {
|
<div className="w-full space-y-5 mb-8">
|
||||||
const isCompleted = currentStep > index;
|
{PHASE_STEPS.map((step, index) => {
|
||||||
const isActive = currentStep === index + 1 && currentStep <= steps.length;
|
const isCompleted = phaseIndex > index;
|
||||||
|
const isActive = phaseIndex === index && phase !== 'complete';
|
||||||
|
const isDone = step.key === 'complete' && phase === 'complete';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={step.key}
|
key={step.key}
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
|
animate={{ opacity: isActive || isCompleted || isDone ? 1 : 0.3, x: 0 }}
|
||||||
transition={{ duration: 0.4, delay: index * 0.15 }}
|
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||||
className="flex items-center gap-4"
|
className="flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
||||||
{isCompleted ? (
|
{isCompleted || isDone ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
|
|
@ -148,33 +187,65 @@ export default function AnalysisLoadingPage() {
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`text-base font-sans transition-colors duration-300 ${isCompleted || isDone ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'}`}>
|
||||||
className={`text-base font-sans transition-colors duration-300 ${
|
{isCompleted ? step.labelDone : step.label}
|
||||||
isCompleted
|
|
||||||
? 'text-white'
|
|
||||||
: isActive
|
|
||||||
? 'text-purple-200'
|
|
||||||
: 'text-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Verified channels panel — shows after Phase 1 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{verifiedChannels && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="w-full mb-8 rounded-xl bg-white/[0.06] border border-white/10 p-4"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-purple-300/60 uppercase tracking-wider mb-3 font-semibold">
|
||||||
|
발견된 채널
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(verifiedChannels).map(([key, value]) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const channels = Array.isArray(value) ? value : [value];
|
||||||
|
return channels.map((ch, i) => (
|
||||||
|
<div key={`${key}-${i}`} className="flex items-center gap-2">
|
||||||
|
{ch.verified ? (
|
||||||
|
<CheckCircle2 size={14} className="text-green-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={14} className="text-red-400/60 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${ch.verified ? 'text-purple-100' : 'text-white/30'}`}>
|
||||||
|
{CHANNEL_LABELS[key] || key}
|
||||||
|
{ch.handle && key !== 'gangnamUnni' ? ` @${ch.handle}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] ml-auto ${ch.verified ? 'text-green-400/60' : 'text-red-400/40'}`}>
|
||||||
|
{ch.verified ? 'verified' : 'not found'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: '0%' }}
|
initial={{ width: '0%' }}
|
||||||
animate={{ width: `${(currentStep / steps.length) * 100}%` }}
|
animate={{ width: `${((phaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
|
||||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-white/40 text-xs mt-4">
|
<p className="text-white/40 text-xs mt-4">
|
||||||
AI가 마케팅 데이터를 분석하고 있습니다. 약 20~30초 소요됩니다.
|
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,12 @@ export default function ClinicProfilePage() {
|
||||||
const chAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
|
const chAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
|
||||||
if (chAnalysis) {
|
if (chAnalysis) {
|
||||||
RATINGS.length = 0;
|
RATINGS.length = 0;
|
||||||
if (chAnalysis.gangnamUnni?.rating) RATINGS.push({ platform: '강남언니', rating: `${chAnalysis.gangnamUnni.rating}`, scale: '/5', reviews: `${chAnalysis.gangnamUnni.reviews ?? '-'}건`, color: '#FF6B8A', pct: ((chAnalysis.gangnamUnni.rating as number) / 5) * 100 });
|
if (chAnalysis.gangnamUnni?.rating) {
|
||||||
|
const guRating = chAnalysis.gangnamUnni.rating as number;
|
||||||
|
const guScale = guRating > 5 ? '/10' : '/5';
|
||||||
|
const guPct = guRating > 5 ? (guRating / 10) * 100 : (guRating / 5) * 100;
|
||||||
|
RATINGS.push({ platform: '강남언니', rating: `${guRating}`, scale: guScale, reviews: `${chAnalysis.gangnamUnni.reviews ?? '-'}건`, color: '#FF6B8A', pct: guPct });
|
||||||
|
}
|
||||||
if (chAnalysis.naverPlace?.rating) RATINGS.push({ platform: '네이버 플레이스', rating: `${chAnalysis.naverPlace.rating}`, scale: '/5', reviews: `${chAnalysis.naverPlace.reviews ?? '-'}건`, color: '#03C75A', pct: ((chAnalysis.naverPlace.rating as number) / 5) * 100 });
|
if (chAnalysis.naverPlace?.rating) RATINGS.push({ platform: '네이버 플레이스', rating: `${chAnalysis.naverPlace.rating}`, scale: '/5', reviews: `${chAnalysis.naverPlace.reviews ?? '-'}건`, color: '#03C75A', pct: ((chAnalysis.naverPlace.rating as number) / 5) * 100 });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
/**
|
||||||
|
* Extract social media handles from a list of URLs.
|
||||||
|
* Parses known platform patterns deterministically — no AI guessing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExtractedSocialLinks {
|
||||||
|
instagram: string[];
|
||||||
|
youtube: string[];
|
||||||
|
facebook: string[];
|
||||||
|
naverBlog: string[];
|
||||||
|
tiktok: string[];
|
||||||
|
kakao: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATTERNS: { platform: keyof ExtractedSocialLinks; regex: RegExp; extract: (m: RegExpMatchArray) => string }[] = [
|
||||||
|
// Instagram: instagram.com/{handle} or instagram.com/p/{postId} (skip posts)
|
||||||
|
{
|
||||||
|
platform: 'instagram',
|
||||||
|
regex: /(?:www\.)?instagram\.com\/([a-zA-Z0-9._]+)\/?(?:\?|$)/,
|
||||||
|
extract: (m) => m[1],
|
||||||
|
},
|
||||||
|
// YouTube: youtube.com/@{handle} or youtube.com/channel/{id} or youtube.com/c/{custom}
|
||||||
|
{
|
||||||
|
platform: 'youtube',
|
||||||
|
regex: /(?:www\.)?youtube\.com\/(?:@([a-zA-Z0-9._-]+)|channel\/(UC[a-zA-Z0-9_-]+)|c\/([a-zA-Z0-9._-]+))/,
|
||||||
|
extract: (m) => m[1] ? `@${m[1]}` : m[2] || m[3] || '',
|
||||||
|
},
|
||||||
|
// Facebook: facebook.com/{page} (skip common paths)
|
||||||
|
{
|
||||||
|
platform: 'facebook',
|
||||||
|
regex: /(?:www\.)?facebook\.com\/([a-zA-Z0-9._-]+)\/?(?:\?|$)/,
|
||||||
|
extract: (m) => m[1],
|
||||||
|
},
|
||||||
|
// Naver Blog: blog.naver.com/{blogId}
|
||||||
|
{
|
||||||
|
platform: 'naverBlog',
|
||||||
|
regex: /blog\.naver\.com\/([a-zA-Z0-9_-]+)/,
|
||||||
|
extract: (m) => m[1],
|
||||||
|
},
|
||||||
|
// TikTok: tiktok.com/@{handle}
|
||||||
|
{
|
||||||
|
platform: 'tiktok',
|
||||||
|
regex: /(?:www\.)?tiktok\.com\/@([a-zA-Z0-9._-]+)/,
|
||||||
|
extract: (m) => m[1],
|
||||||
|
},
|
||||||
|
// KakaoTalk Channel: pf.kakao.com/{id}
|
||||||
|
{
|
||||||
|
platform: 'kakao',
|
||||||
|
regex: /pf\.kakao\.com\/([a-zA-Z0-9_-]+)/,
|
||||||
|
extract: (m) => m[1],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Common Facebook paths that are NOT page names
|
||||||
|
const FB_SKIP = new Set([
|
||||||
|
'sharer', 'share', 'login', 'help', 'pages', 'events', 'groups',
|
||||||
|
'marketplace', 'watch', 'gaming', 'privacy', 'policies', 'tr',
|
||||||
|
'dialog', 'plugins', 'photo', 'video', 'reel',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Common Instagram paths that are NOT handles
|
||||||
|
const IG_SKIP = new Set([
|
||||||
|
'p', 'reel', 'reels', 'stories', 'explore', 'accounts', 'about',
|
||||||
|
'developer', 'legal', 'privacy', 'terms',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function extractSocialLinks(urls: string[]): ExtractedSocialLinks {
|
||||||
|
const result: ExtractedSocialLinks = {
|
||||||
|
instagram: [],
|
||||||
|
youtube: [],
|
||||||
|
facebook: [],
|
||||||
|
naverBlog: [],
|
||||||
|
tiktok: [],
|
||||||
|
kakao: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const seen: Record<string, Set<string>> = {};
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
seen[key] = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
if (!url || typeof url !== 'string') continue;
|
||||||
|
|
||||||
|
for (const { platform, regex, extract } of PATTERNS) {
|
||||||
|
const match = url.match(regex);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const handle = extract(match);
|
||||||
|
if (!handle || handle.length < 2) continue;
|
||||||
|
|
||||||
|
// Skip known non-handle paths
|
||||||
|
if (platform === 'facebook' && FB_SKIP.has(handle.toLowerCase())) continue;
|
||||||
|
if (platform === 'instagram' && IG_SKIP.has(handle.toLowerCase())) continue;
|
||||||
|
|
||||||
|
const normalized = handle.toLowerCase();
|
||||||
|
if (!seen[platform].has(normalized)) {
|
||||||
|
seen[platform].add(normalized);
|
||||||
|
result[platform].push(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge social links from multiple sources, deduplicating.
|
||||||
|
*/
|
||||||
|
export function mergeSocialLinks(...sources: Partial<ExtractedSocialLinks>[]): ExtractedSocialLinks {
|
||||||
|
const merged: ExtractedSocialLinks = {
|
||||||
|
instagram: [],
|
||||||
|
youtube: [],
|
||||||
|
facebook: [],
|
||||||
|
naverBlog: [],
|
||||||
|
tiktok: [],
|
||||||
|
kakao: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const key of Object.keys(merged) as (keyof ExtractedSocialLinks)[]) {
|
||||||
|
const vals = source[key];
|
||||||
|
if (Array.isArray(vals)) {
|
||||||
|
for (const v of vals) {
|
||||||
|
if (v && !merged[key].some(existing => existing.toLowerCase() === v.toLowerCase())) {
|
||||||
|
merged[key].push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
/**
|
||||||
|
* Verify social media handles exist via lightweight API checks.
|
||||||
|
* Each check runs independently — one failure doesn't block others.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VerifiedChannel {
|
||||||
|
handle: string;
|
||||||
|
verified: boolean;
|
||||||
|
url?: string;
|
||||||
|
channelId?: string; // YouTube channel ID if resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifiedChannels {
|
||||||
|
instagram: VerifiedChannel[];
|
||||||
|
youtube: VerifiedChannel | null;
|
||||||
|
facebook: VerifiedChannel | null;
|
||||||
|
naverBlog: VerifiedChannel | null;
|
||||||
|
gangnamUnni: VerifiedChannel | null;
|
||||||
|
tiktok: VerifiedChannel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an Instagram handle exists.
|
||||||
|
* Uses a lightweight fetch to the profile page.
|
||||||
|
*/
|
||||||
|
async function verifyInstagram(handle: string): Promise<VerifiedChannel> {
|
||||||
|
try {
|
||||||
|
const url = `https://www.instagram.com/${handle}/`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
// Instagram returns 200 for existing profiles, 404 for missing
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
verified: res.status === 200,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { handle, verified: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a YouTube handle/channel exists using YouTube Data API v3.
|
||||||
|
*/
|
||||||
|
async function verifyYouTube(handle: string, apiKey: string): Promise<VerifiedChannel> {
|
||||||
|
try {
|
||||||
|
const YT_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||||
|
const cleanHandle = handle.replace(/^@/, '');
|
||||||
|
|
||||||
|
// Try forHandle first, then forUsername
|
||||||
|
for (const param of ['forHandle', 'forUsername']) {
|
||||||
|
const res = await fetch(`${YT_BASE}/channels?part=id,snippet&${param}=${cleanHandle}&key=${apiKey}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const channel = data.items?.[0];
|
||||||
|
if (channel) {
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
verified: true,
|
||||||
|
channelId: channel.id,
|
||||||
|
url: `https://youtube.com/@${cleanHandle}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as channel ID directly (starts with UC)
|
||||||
|
if (handle.startsWith('UC')) {
|
||||||
|
const res = await fetch(`${YT_BASE}/channels?part=id&id=${handle}&key=${apiKey}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.items?.[0]) {
|
||||||
|
return { handle, verified: true, channelId: handle, url: `https://youtube.com/channel/${handle}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handle, verified: false };
|
||||||
|
} catch {
|
||||||
|
return { handle, verified: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a Facebook page exists via HEAD request.
|
||||||
|
*/
|
||||||
|
async function verifyFacebook(handle: string): Promise<VerifiedChannel> {
|
||||||
|
try {
|
||||||
|
const url = `https://www.facebook.com/${handle}/`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
return { handle, verified: res.status === 200, url };
|
||||||
|
} catch {
|
||||||
|
return { handle, verified: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Naver Blog exists.
|
||||||
|
*/
|
||||||
|
async function verifyNaverBlog(blogId: string): Promise<VerifiedChannel> {
|
||||||
|
try {
|
||||||
|
const url = `https://blog.naver.com/${blogId}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
return { handle: blogId, verified: res.status === 200, url };
|
||||||
|
} catch {
|
||||||
|
return { handle: blogId, verified: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and verify gangnamunni hospital page using Firecrawl search.
|
||||||
|
*/
|
||||||
|
async function verifyGangnamUnni(
|
||||||
|
clinicName: string,
|
||||||
|
firecrawlKey: string,
|
||||||
|
hintUrl?: string,
|
||||||
|
): Promise<VerifiedChannel> {
|
||||||
|
try {
|
||||||
|
// If we already have a URL hint from Perplexity, just verify it
|
||||||
|
if (hintUrl && hintUrl.includes('gangnamunni.com/hospitals/')) {
|
||||||
|
const res = await fetch(hintUrl, { method: 'HEAD', redirect: 'follow' });
|
||||||
|
if (res.status === 200) {
|
||||||
|
return { handle: clinicName, verified: true, url: hintUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, search with multiple fallback queries
|
||||||
|
const shortName = clinicName.replace(/성형외과|의원|병원|클리닉|피부과/g, '').trim();
|
||||||
|
const queries = [
|
||||||
|
`${clinicName} site:gangnamunni.com`,
|
||||||
|
`${shortName} 성형외과 site:gangnamunni.com`,
|
||||||
|
`${clinicName} 강남언니`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
const searchRes = await fetch('https://api.firecrawl.dev/v1/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${firecrawlKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, limit: 5 }),
|
||||||
|
});
|
||||||
|
const data = await searchRes.json();
|
||||||
|
const url = (data.data || [])
|
||||||
|
.map((r: Record<string, string>) => r.url)
|
||||||
|
.find((u: string) => u?.includes('gangnamunni.com/hospitals/'));
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return { handle: clinicName, verified: true, url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handle: clinicName, verified: false };
|
||||||
|
} catch {
|
||||||
|
return { handle: clinicName, verified: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all discovered handles in parallel.
|
||||||
|
*/
|
||||||
|
export async function verifyAllHandles(
|
||||||
|
candidates: {
|
||||||
|
instagram: string[];
|
||||||
|
youtube: string[];
|
||||||
|
facebook: string[];
|
||||||
|
naverBlog: string[];
|
||||||
|
tiktok: string[];
|
||||||
|
},
|
||||||
|
clinicName: string,
|
||||||
|
gangnamUnniHintUrl?: string,
|
||||||
|
): Promise<VerifiedChannels> {
|
||||||
|
const YOUTUBE_API_KEY = Deno.env.get('YOUTUBE_API_KEY') || '';
|
||||||
|
const FIRECRAWL_API_KEY = Deno.env.get('FIRECRAWL_API_KEY') || '';
|
||||||
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
const result: VerifiedChannels = {
|
||||||
|
instagram: [],
|
||||||
|
youtube: null,
|
||||||
|
facebook: null,
|
||||||
|
naverBlog: null,
|
||||||
|
gangnamUnni: null,
|
||||||
|
tiktok: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Instagram — verify each candidate
|
||||||
|
for (const handle of candidates.instagram.slice(0, 5)) {
|
||||||
|
tasks.push(
|
||||||
|
verifyInstagram(handle).then(v => { if (v.verified) result.instagram.push(v); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube — first candidate
|
||||||
|
if (candidates.youtube.length > 0) {
|
||||||
|
tasks.push(
|
||||||
|
verifyYouTube(candidates.youtube[0], YOUTUBE_API_KEY).then(v => { result.youtube = v; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facebook — first candidate
|
||||||
|
if (candidates.facebook.length > 0) {
|
||||||
|
tasks.push(
|
||||||
|
verifyFacebook(candidates.facebook[0]).then(v => { result.facebook = v; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naver Blog — first candidate
|
||||||
|
if (candidates.naverBlog.length > 0) {
|
||||||
|
tasks.push(
|
||||||
|
verifyNaverBlog(candidates.naverBlog[0]).then(v => { result.naverBlog = v; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강남언니 — always try if clinicName exists
|
||||||
|
if (clinicName && FIRECRAWL_API_KEY) {
|
||||||
|
tasks.push(
|
||||||
|
verifyGangnamUnni(clinicName, FIRECRAWL_API_KEY, gangnamUnniHintUrl)
|
||||||
|
.then(v => { result.gangnamUnni = v; })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TikTok — skip verification for now (TikTok blocks HEAD requests)
|
||||||
|
if (candidates.tiktok.length > 0) {
|
||||||
|
result.tiktok = { handle: candidates.tiktok[0], verified: false, url: `https://tiktok.com/@${candidates.tiktok[0]}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(tasks);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
const APIFY_BASE = "https://api.apify.com/v2";
|
||||||
|
|
||||||
|
interface CollectRequest {
|
||||||
|
reportId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runApifyActor(actorId: string, input: Record<string, unknown>, token: string): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${APIFY_BASE}/acts/${actorId}/runs?token=${token}&waitForFinish=120`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const run = await res.json();
|
||||||
|
const datasetId = run.data?.defaultDatasetId;
|
||||||
|
if (!datasetId) return [];
|
||||||
|
const itemsRes = await fetch(`${APIFY_BASE}/datasets/${datasetId}/items?token=${token}&limit=20`);
|
||||||
|
return itemsRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Collect Channel Data
|
||||||
|
*
|
||||||
|
* Uses verified handles from Phase 1 (stored in DB) to collect ALL raw data
|
||||||
|
* from each channel in parallel. Also runs market analysis via Perplexity.
|
||||||
|
*/
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { reportId } = (await req.json()) as CollectRequest;
|
||||||
|
if (!reportId) throw new Error("reportId is required");
|
||||||
|
|
||||||
|
// Read Phase 1 results from DB
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
const { data: row, error: fetchError } = await supabase
|
||||||
|
.from("marketing_reports")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", reportId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError || !row) throw new Error(`Report not found: ${fetchError?.message}`);
|
||||||
|
|
||||||
|
const verified = row.verified_channels as VerifiedChannels;
|
||||||
|
const clinicName = row.clinic_name || "";
|
||||||
|
const address = row.scrape_data?.clinic?.address || "";
|
||||||
|
const services: string[] = row.scrape_data?.clinic?.services || [];
|
||||||
|
|
||||||
|
await supabase.from("marketing_reports").update({ status: "collecting" }).eq("id", reportId);
|
||||||
|
|
||||||
|
const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN") || "";
|
||||||
|
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY") || "";
|
||||||
|
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY") || "";
|
||||||
|
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY") || "";
|
||||||
|
const NAVER_CLIENT_ID = Deno.env.get("NAVER_CLIENT_ID") || "";
|
||||||
|
const NAVER_CLIENT_SECRET = Deno.env.get("NAVER_CLIENT_SECRET") || "";
|
||||||
|
|
||||||
|
const channelData: Record<string, unknown> = {};
|
||||||
|
const analysisData: Record<string, unknown> = {};
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// ─── 1. Instagram (multi-account) ───
|
||||||
|
if (APIFY_TOKEN && verified.instagram?.length > 0) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const accounts: Record<string, unknown>[] = [];
|
||||||
|
for (const ig of verified.instagram.filter(v => v.verified)) {
|
||||||
|
const items = await runApifyActor("apify~instagram-profile-scraper", { usernames: [ig.handle], resultsLimit: 12 }, APIFY_TOKEN);
|
||||||
|
const profile = (items as Record<string, unknown>[])[0];
|
||||||
|
if (profile && !profile.error) {
|
||||||
|
accounts.push({
|
||||||
|
username: profile.username,
|
||||||
|
followers: profile.followersCount,
|
||||||
|
following: profile.followsCount,
|
||||||
|
posts: profile.postsCount,
|
||||||
|
bio: profile.biography,
|
||||||
|
isBusinessAccount: profile.isBusinessAccount,
|
||||||
|
externalUrl: profile.externalUrl,
|
||||||
|
igtvVideoCount: profile.igtvVideoCount,
|
||||||
|
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || []).slice(0, 12).map(p => ({
|
||||||
|
type: p.type, likes: p.likesCount, comments: p.commentsCount,
|
||||||
|
caption: p.caption, timestamp: p.timestamp,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
channelData.instagramAccounts = accounts;
|
||||||
|
channelData.instagram = accounts[0];
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. YouTube ───
|
||||||
|
if (YOUTUBE_API_KEY && verified.youtube?.verified) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const YT = "https://www.googleapis.com/youtube/v3";
|
||||||
|
const channelId = verified.youtube!.channelId || "";
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
const chRes = await fetch(`${YT}/channels?part=snippet,statistics,brandingSettings&id=${channelId}&key=${YOUTUBE_API_KEY}`);
|
||||||
|
const chData = await chRes.json();
|
||||||
|
const channel = chData.items?.[0];
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
const stats = channel.statistics || {};
|
||||||
|
const snippet = channel.snippet || {};
|
||||||
|
|
||||||
|
// Popular videos
|
||||||
|
const searchRes = await fetch(`${YT}/search?part=snippet&channelId=${channelId}&order=viewCount&type=video&maxResults=10&key=${YOUTUBE_API_KEY}`);
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
const videoIds = (searchData.items || []).map((i: Record<string, unknown>) => (i.id as Record<string, string>)?.videoId).filter(Boolean).join(",");
|
||||||
|
|
||||||
|
let videos: Record<string, unknown>[] = [];
|
||||||
|
if (videoIds) {
|
||||||
|
const vRes = await fetch(`${YT}/videos?part=snippet,statistics,contentDetails&id=${videoIds}&key=${YOUTUBE_API_KEY}`);
|
||||||
|
const vData = await vRes.json();
|
||||||
|
videos = vData.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
channelData.youtube = {
|
||||||
|
channelId, channelName: snippet.title, handle: snippet.customUrl,
|
||||||
|
description: snippet.description, publishedAt: snippet.publishedAt,
|
||||||
|
thumbnailUrl: snippet.thumbnails?.default?.url,
|
||||||
|
subscribers: parseInt(stats.subscriberCount || "0", 10),
|
||||||
|
totalViews: parseInt(stats.viewCount || "0", 10),
|
||||||
|
totalVideos: parseInt(stats.videoCount || "0", 10),
|
||||||
|
videos: videos.slice(0, 10).map(v => {
|
||||||
|
const vs = v.statistics as Record<string, string> || {};
|
||||||
|
const vSnip = v.snippet as Record<string, unknown> || {};
|
||||||
|
const vCon = v.contentDetails as Record<string, string> || {};
|
||||||
|
return {
|
||||||
|
title: vSnip.title, views: parseInt(vs.viewCount || "0", 10),
|
||||||
|
likes: parseInt(vs.likeCount || "0", 10), comments: parseInt(vs.commentCount || "0", 10),
|
||||||
|
date: vSnip.publishedAt, duration: vCon.duration,
|
||||||
|
url: `https://www.youtube.com/watch?v=${v.id}`,
|
||||||
|
thumbnail: (vSnip.thumbnails as Record<string, Record<string, string>>)?.medium?.url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. Facebook ───
|
||||||
|
if (APIFY_TOKEN && verified.facebook?.verified) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const fbUrl = verified.facebook!.url || `https://www.facebook.com/${verified.facebook!.handle}`;
|
||||||
|
const items = await runApifyActor("apify~facebook-pages-scraper", { startUrls: [{ url: fbUrl }] }, APIFY_TOKEN);
|
||||||
|
const page = (items as Record<string, unknown>[])[0];
|
||||||
|
if (page?.title) {
|
||||||
|
channelData.facebook = {
|
||||||
|
pageName: page.title, pageUrl: page.pageUrl || fbUrl,
|
||||||
|
followers: page.followers, likes: page.likes, categories: page.categories,
|
||||||
|
email: page.email, phone: page.phone, website: page.website,
|
||||||
|
address: page.address, intro: page.intro, rating: page.rating,
|
||||||
|
profilePictureUrl: page.profilePictureUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. 강남언니 ───
|
||||||
|
if (FIRECRAWL_API_KEY && verified.gangnamUnni?.verified && verified.gangnamUnni.url) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const scrapeRes = await fetch("https://api.firecrawl.dev/v1/scrape", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: verified.gangnamUnni!.url,
|
||||||
|
formats: ["json"],
|
||||||
|
jsonOptions: {
|
||||||
|
prompt: "Extract: hospital name, overall rating (out of 10), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
hospitalName: { type: "string" }, rating: { type: "number" }, totalReviews: { type: "number" },
|
||||||
|
doctors: { type: "array", items: { type: "object", properties: { name: { type: "string" }, rating: { type: "number" }, reviews: { type: "number" }, specialty: { type: "string" } } } },
|
||||||
|
procedures: { type: "array", items: { type: "string" } },
|
||||||
|
address: { type: "string" }, badges: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
waitFor: 5000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await scrapeRes.json();
|
||||||
|
const hospital = data.data?.json;
|
||||||
|
if (hospital?.hospitalName) {
|
||||||
|
channelData.gangnamUnni = {
|
||||||
|
name: hospital.hospitalName, rating: hospital.rating, ratingScale: "/10",
|
||||||
|
totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10),
|
||||||
|
procedures: hospital.procedures || [], address: hospital.address,
|
||||||
|
badges: hospital.badges || [], sourceUrl: verified.gangnamUnni!.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Naver Blog + Place ───
|
||||||
|
if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET && clinicName) {
|
||||||
|
const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET };
|
||||||
|
|
||||||
|
tasks.push((async () => {
|
||||||
|
const query = encodeURIComponent(`${clinicName} 후기`);
|
||||||
|
const res = await fetch(`https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, { headers: naverHeaders });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
channelData.naverBlog = {
|
||||||
|
totalResults: data.total || 0, searchQuery: `${clinicName} 후기`,
|
||||||
|
posts: (data.items || []).slice(0, 10).map((item: Record<string, string>) => ({
|
||||||
|
title: (item.title || "").replace(/<[^>]*>/g, ""),
|
||||||
|
description: (item.description || "").replace(/<[^>]*>/g, ""),
|
||||||
|
link: item.link, bloggerName: item.bloggername, postDate: item.postdate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})());
|
||||||
|
|
||||||
|
tasks.push((async () => {
|
||||||
|
const query = encodeURIComponent(clinicName);
|
||||||
|
const res = await fetch(`https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, { headers: naverHeaders });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const place = (data.items || [])[0];
|
||||||
|
if (place) {
|
||||||
|
channelData.naverPlace = {
|
||||||
|
name: (place.title || "").replace(/<[^>]*>/g, ""),
|
||||||
|
category: place.category, address: place.roadAddress || place.address,
|
||||||
|
telephone: place.telephone, link: place.link, mapx: place.mapx, mapy: place.mapy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 6. Google Maps ───
|
||||||
|
if (APIFY_TOKEN && clinicName) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const queries = [`${clinicName} 성형외과`, clinicName, `${clinicName} ${address || "강남"}`];
|
||||||
|
let items: unknown[] = [];
|
||||||
|
for (const q of queries) {
|
||||||
|
items = await runApifyActor("compass~crawler-google-places", {
|
||||||
|
searchStringsArray: [q], maxCrawledPlacesPerSearch: 3, language: "ko", maxReviews: 10,
|
||||||
|
}, APIFY_TOKEN);
|
||||||
|
if ((items as Record<string, unknown>[]).length > 0) break;
|
||||||
|
}
|
||||||
|
const place = (items as Record<string, unknown>[])[0];
|
||||||
|
if (place) {
|
||||||
|
channelData.googleMaps = {
|
||||||
|
name: place.title, rating: place.totalScore, reviewCount: place.reviewsCount,
|
||||||
|
address: place.address, phone: place.phone, website: place.website,
|
||||||
|
category: place.categoryName, openingHours: place.openingHours,
|
||||||
|
topReviews: ((place.reviews as Record<string, unknown>[]) || []).slice(0, 10).map(r => ({
|
||||||
|
stars: r.stars, text: r.text, publishedAtDate: r.publishedAtDate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 7. Market Analysis (Perplexity) ───
|
||||||
|
if (PERPLEXITY_API_KEY && services.length > 0) {
|
||||||
|
tasks.push((async () => {
|
||||||
|
const queries = [
|
||||||
|
{ id: "competitors", prompt: `${address || "강남"} 근처 ${services.slice(0, 3).join(", ")} 전문 성형외과/피부과 경쟁 병원 5곳을 분석해줘. 각 병원의 이름, 주요 시술, 온라인 평판, 마케팅 채널을 JSON 형식으로 제공해줘.` },
|
||||||
|
{ id: "keywords", prompt: `한국 ${services.slice(0, 3).join(", ")} 관련 검색 키워드 트렌드. 네이버와 구글에서 월간 검색량이 높은 키워드 20개, 경쟁 강도, 추천 롱테일 키워드를 JSON 형식으로 제공해줘.` },
|
||||||
|
{ id: "market", prompt: `한국 ${services[0] || "성형외과"} 시장 트렌드 2025-2026. 시장 규모, 성장률, 주요 트렌드, 마케팅 채널별 효과를 JSON 형식으로 제공해줘.` },
|
||||||
|
{ id: "targetAudience", prompt: `${clinicName}의 잠재 고객 분석. 연령대별, 성별, 관심 시술, 정보 탐색 채널, 의사결정 요인을 JSON 형식으로 제공해줘.` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(queries.map(async q => {
|
||||||
|
const res = await fetch("https://api.perplexity.ai/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "sonar", messages: [
|
||||||
|
{ role: "system", content: "You are a Korean medical marketing analyst. Always respond in Korean. Provide data in valid JSON format." },
|
||||||
|
{ role: "user", content: q.prompt },
|
||||||
|
], temperature: 0.3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return { id: q.id, content: data.choices?.[0]?.message?.content || "", citations: data.citations || [] };
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
const { id, content, citations } = r.value;
|
||||||
|
let parsed = content;
|
||||||
|
const jsonMatch = content.match(/```json\n?([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) { try { parsed = JSON.parse(jsonMatch[1]); } catch {} }
|
||||||
|
analysisData[id] = { data: parsed, citations };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Execute all tasks ───
|
||||||
|
await Promise.allSettled(tasks);
|
||||||
|
|
||||||
|
// ─── Save to DB ───
|
||||||
|
await supabase.from("marketing_reports").update({
|
||||||
|
channel_data: channelData,
|
||||||
|
analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() },
|
||||||
|
status: "collected",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}).eq("id", reportId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, error: error.message }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
import { extractSocialLinks, mergeSocialLinks } from "../_shared/extractSocialLinks.ts";
|
||||||
|
import { verifyAllHandles, type VerifiedChannels } from "../_shared/verifyHandles.ts";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DiscoverRequest {
|
||||||
|
url: string;
|
||||||
|
clinicName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Discover & Verify Channels
|
||||||
|
*
|
||||||
|
* 3-source channel discovery:
|
||||||
|
* A. Firecrawl scrape + map → extract social links from HTML
|
||||||
|
* B. Perplexity search → find social handles via web search
|
||||||
|
* C. Merge + deduplicate → verify each handle exists
|
||||||
|
*/
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, clinicName } = (await req.json()) as DiscoverRequest;
|
||||||
|
if (!url) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "URL is required" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY");
|
||||||
|
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY");
|
||||||
|
if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY not configured");
|
||||||
|
|
||||||
|
// ─── A. Parallel: Firecrawl scrape/map + Perplexity search ───
|
||||||
|
|
||||||
|
const [scrapeResult, mapResult, brandResult, perplexityResult] = await Promise.allSettled([
|
||||||
|
// A1. Scrape website — structured JSON + links
|
||||||
|
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", "links"],
|
||||||
|
jsonOptions: {
|
||||||
|
prompt: "Extract: clinic name, address, phone, services offered, doctors with specialties, social media links (instagram, youtube, blog, facebook, tiktok, kakao), business hours, slogan",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
clinicName: { type: "string" },
|
||||||
|
address: { type: "string" },
|
||||||
|
phone: { type: "string" },
|
||||||
|
businessHours: { type: "string" },
|
||||||
|
slogan: { type: "string" },
|
||||||
|
services: { type: "array", items: { type: "string" } },
|
||||||
|
doctors: { type: "array", items: { type: "object", properties: { name: { type: "string" }, title: { type: "string" }, specialty: { type: "string" } } } },
|
||||||
|
socialMedia: { type: "object", properties: { instagram: { type: "string" }, youtube: { type: "string" }, blog: { type: "string" }, facebook: { type: "string" }, tiktok: { type: "string" }, kakao: { type: "string" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
waitFor: 5000,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()),
|
||||||
|
|
||||||
|
// A2. Map site — discover all linked pages
|
||||||
|
fetch("https://api.firecrawl.dev/v1/map", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
|
||||||
|
body: JSON.stringify({ url, limit: 50 }),
|
||||||
|
}).then(r => r.json()),
|
||||||
|
|
||||||
|
// A3. Branding extraction
|
||||||
|
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: primary/accent/background/text colors (hex), heading/body fonts, logo URL, favicon URL, tagline",
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()).catch(() => ({ data: { json: {} } })),
|
||||||
|
|
||||||
|
// A4. Perplexity — find social handles via web search
|
||||||
|
PERPLEXITY_API_KEY
|
||||||
|
? Promise.allSettled([
|
||||||
|
// Query 1: Social media handles
|
||||||
|
fetch("https://api.perplexity.ai/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "sonar",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You find official social media accounts for Korean medical clinics. Respond ONLY with valid JSON. If unsure, use null. Never guess." },
|
||||||
|
{ role: "user", content: `"${clinicName || url}" 성형외과의 공식 소셜 미디어 계정을 찾아줘. 반드시 확인된 계정만 포함.\n\n{"instagram": ["핸들1", "핸들2"], "youtube": "핸들 또는 URL", "facebook": "페이지명", "tiktok": "핸들", "naverBlog": "블로그ID", "kakao": "채널ID"}` },
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()),
|
||||||
|
|
||||||
|
// Query 2: Platform presence (강남언니, 네이버, 바비톡)
|
||||||
|
fetch("https://api.perplexity.ai/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "sonar",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You research Korean medical clinic platform presence. Respond ONLY with valid JSON." },
|
||||||
|
{ role: "user", content: `"${clinicName || url}" 성형외과의 강남언니, 네이버 플레이스, 바비톡 등록 현황을 찾아줘.\n\n{"gangnamUnni": {"registered": true/false, "url": "URL 또는 null", "rating": 숫자 또는 null}, "naverPlace": {"registered": true/false, "rating": 숫자 또는 null}, "babitok": {"registered": true/false}}` },
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()),
|
||||||
|
])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─── B. Parse results ───
|
||||||
|
|
||||||
|
const scrapeData = scrapeResult.status === "fulfilled" ? scrapeResult.value : { data: {} };
|
||||||
|
const mapData = mapResult.status === "fulfilled" ? mapResult.value : {};
|
||||||
|
const brandData = brandResult.status === "fulfilled" ? brandResult.value : { data: { json: {} } };
|
||||||
|
|
||||||
|
const clinic = scrapeData.data?.json || {};
|
||||||
|
const resolvedName = clinicName || clinic.clinicName || url;
|
||||||
|
const siteLinks: string[] = scrapeData.data?.links || [];
|
||||||
|
const siteMap: string[] = mapData.links || [];
|
||||||
|
const allUrls = [...siteLinks, ...siteMap];
|
||||||
|
|
||||||
|
// Source 1: Parse links from HTML
|
||||||
|
const linkHandles = extractSocialLinks(allUrls);
|
||||||
|
|
||||||
|
// Source 2: Parse Firecrawl JSON extraction socialMedia field
|
||||||
|
const scrapeSocial = clinic.socialMedia || {};
|
||||||
|
const firecrawlHandles: Partial<typeof linkHandles> = {
|
||||||
|
instagram: scrapeSocial.instagram ? [scrapeSocial.instagram] : [],
|
||||||
|
youtube: scrapeSocial.youtube ? [scrapeSocial.youtube] : [],
|
||||||
|
facebook: scrapeSocial.facebook ? [scrapeSocial.facebook] : [],
|
||||||
|
naverBlog: scrapeSocial.blog ? [scrapeSocial.blog] : [],
|
||||||
|
tiktok: scrapeSocial.tiktok ? [scrapeSocial.tiktok] : [],
|
||||||
|
kakao: scrapeSocial.kakao ? [scrapeSocial.kakao] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source 3: Parse Perplexity results
|
||||||
|
let perplexityHandles: Partial<typeof linkHandles> = {};
|
||||||
|
let gangnamUnniHintUrl: string | undefined;
|
||||||
|
|
||||||
|
if (perplexityResult.status === "fulfilled" && Array.isArray(perplexityResult.value)) {
|
||||||
|
const pResults = perplexityResult.value;
|
||||||
|
|
||||||
|
// Social handles query
|
||||||
|
if (pResults[0]?.status === "fulfilled") {
|
||||||
|
try {
|
||||||
|
let text = pResults[0].value?.choices?.[0]?.message?.content || "";
|
||||||
|
const jsonMatch = text.match(/```(?:json)?\n?([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) text = jsonMatch[1];
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
perplexityHandles = {
|
||||||
|
instagram: Array.isArray(parsed.instagram) ? parsed.instagram : parsed.instagram ? [parsed.instagram] : [],
|
||||||
|
youtube: parsed.youtube ? [parsed.youtube] : [],
|
||||||
|
facebook: parsed.facebook ? [parsed.facebook] : [],
|
||||||
|
naverBlog: parsed.naverBlog ? [parsed.naverBlog] : [],
|
||||||
|
tiktok: parsed.tiktok ? [parsed.tiktok] : [],
|
||||||
|
kakao: parsed.kakao ? [parsed.kakao] : [],
|
||||||
|
};
|
||||||
|
} catch { /* JSON parse failed — skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform presence query
|
||||||
|
if (pResults[1]?.status === "fulfilled") {
|
||||||
|
try {
|
||||||
|
let text = pResults[1].value?.choices?.[0]?.message?.content || "";
|
||||||
|
const jsonMatch = text.match(/```(?:json)?\n?([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) text = jsonMatch[1];
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed.gangnamUnni?.url) {
|
||||||
|
gangnamUnniHintUrl = parsed.gangnamUnni.url;
|
||||||
|
}
|
||||||
|
} catch { /* JSON parse failed — skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── C. Merge + Deduplicate + Verify ───
|
||||||
|
|
||||||
|
const merged = mergeSocialLinks(linkHandles, firecrawlHandles, perplexityHandles);
|
||||||
|
|
||||||
|
// Clean up handles (remove @ prefix, URL parts)
|
||||||
|
const cleanHandles = {
|
||||||
|
instagram: merged.instagram.map(h => h.replace(/^@/, '').replace(/\/$/, '')).filter(h => h.length > 1),
|
||||||
|
youtube: merged.youtube.map(h => h.replace(/^https?:\/\/(www\.)?youtube\.com\//, '')).filter(h => h.length > 1),
|
||||||
|
facebook: merged.facebook.map(h => h.replace(/^@/, '').replace(/\/$/, '')).filter(h => h.length > 1),
|
||||||
|
naverBlog: merged.naverBlog.filter(h => h.length > 1),
|
||||||
|
tiktok: merged.tiktok.map(h => h.replace(/^@/, '')).filter(h => h.length > 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
const verified: VerifiedChannels = await verifyAllHandles(
|
||||||
|
cleanHandles,
|
||||||
|
resolvedName,
|
||||||
|
gangnamUnniHintUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── D. Save to DB ───
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
const scrapeDataFull = {
|
||||||
|
clinic,
|
||||||
|
branding: brandData.data?.json || {},
|
||||||
|
siteLinks,
|
||||||
|
siteMap: mapData.links || [],
|
||||||
|
sourceUrl: url,
|
||||||
|
scrapedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: saved, error: saveError } = await supabase
|
||||||
|
.from("marketing_reports")
|
||||||
|
.insert({
|
||||||
|
url,
|
||||||
|
clinic_name: resolvedName,
|
||||||
|
status: "discovered",
|
||||||
|
verified_channels: verified,
|
||||||
|
scrape_data: scrapeDataFull,
|
||||||
|
report: {},
|
||||||
|
pipeline_started_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (saveError) throw new Error(`DB save failed: ${saveError.message}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
reportId: saved.id,
|
||||||
|
clinicName: resolvedName,
|
||||||
|
verifiedChannels: verified,
|
||||||
|
address: clinic.address || "",
|
||||||
|
services: clinic.services || [],
|
||||||
|
scrapeData: scrapeDataFull,
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, error: error.message }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -107,8 +107,10 @@ Deno.serve(async (req) => {
|
||||||
bio: profile.biography,
|
bio: profile.biography,
|
||||||
isBusinessAccount: profile.isBusinessAccount,
|
isBusinessAccount: profile.isBusinessAccount,
|
||||||
externalUrl: profile.externalUrl,
|
externalUrl: profile.externalUrl,
|
||||||
|
igtvVideoCount: profile.igtvVideoCount,
|
||||||
|
highlightsCount: profile.highlightsCount,
|
||||||
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
|
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
|
||||||
.slice(0, 6)
|
.slice(0, 12)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
type: p.type,
|
type: p.type,
|
||||||
likes: p.likesCount,
|
likes: p.likesCount,
|
||||||
|
|
@ -189,21 +191,34 @@ Deno.serve(async (req) => {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
// Step 1: Search for the clinic's gangnamunni page
|
// Step 1: Search for the clinic's gangnamunni page
|
||||||
|
// Try multiple search queries for better matching
|
||||||
|
const searchQueries = [
|
||||||
|
`${clinicName} site:gangnamunni.com`,
|
||||||
|
`${clinicName.replace(/성형외과|의원|병원|클리닉/g, '')} 성형외과 site:gangnamunni.com`,
|
||||||
|
`${clinicName} 강남언니`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let hospitalUrl: string | undefined;
|
||||||
|
|
||||||
|
for (const query of searchQueries) {
|
||||||
|
if (hospitalUrl) break;
|
||||||
|
try {
|
||||||
const searchRes = await fetch("https://api.firecrawl.dev/v1/search", {
|
const searchRes = await fetch("https://api.firecrawl.dev/v1/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ query, limit: 5 }),
|
||||||
query: `${clinicName} site:gangnamunni.com`,
|
|
||||||
limit: 3,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const searchData = await searchRes.json();
|
const searchData = await searchRes.json();
|
||||||
const hospitalUrl = (searchData.data || [])
|
hospitalUrl = (searchData.data || [])
|
||||||
.map((r: Record<string, string>) => r.url)
|
.map((r: Record<string, string>) => r.url)
|
||||||
.find((u: string) => u?.includes("gangnamunni.com/hospitals/"));
|
.find((u: string) => u?.includes("gangnamunni.com/hospitals/"));
|
||||||
|
} catch {
|
||||||
|
// Try next query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hospitalUrl) return;
|
if (!hospitalUrl) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ const corsHeaders = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ReportRequest {
|
interface ReportRequest {
|
||||||
url: string;
|
// V2: reportId-based (Phase 3 — uses data already in DB)
|
||||||
|
reportId?: string;
|
||||||
|
// V1 compat: url-based (legacy single-call flow)
|
||||||
|
url?: string;
|
||||||
clinicName?: string;
|
clinicName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,144 +22,97 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url, clinicName } = (await req.json()) as ReportRequest;
|
const body = (await req.json()) as ReportRequest;
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "URL is required" }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY");
|
const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY");
|
||||||
if (!PERPLEXITY_API_KEY) {
|
if (!PERPLEXITY_API_KEY) throw new Error("PERPLEXITY_API_KEY not configured");
|
||||||
throw new Error("PERPLEXITY_API_KEY not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
// Step 1: Call scrape-website function
|
// ─── V2 Pipeline: reportId provided (Phase 1 & 2 already ran) ───
|
||||||
const scrapeRes = await fetch(`${supabaseUrl}/functions/v1/scrape-website`, {
|
if (body.reportId) {
|
||||||
method: "POST",
|
const { data: row, error: fetchErr } = await supabase
|
||||||
headers: {
|
.from("marketing_reports")
|
||||||
"Content-Type": "application/json",
|
.select("*")
|
||||||
Authorization: `Bearer ${supabaseKey}`,
|
.eq("id", body.reportId)
|
||||||
},
|
.single();
|
||||||
body: JSON.stringify({ url, clinicName }),
|
if (fetchErr || !row) throw new Error(`Report not found: ${fetchErr?.message}`);
|
||||||
});
|
|
||||||
const scrapeResult = await scrapeRes.json();
|
|
||||||
|
|
||||||
if (!scrapeResult.success) {
|
await supabase.from("marketing_reports").update({ status: "generating" }).eq("id", body.reportId);
|
||||||
throw new Error(`Scraping failed: ${scrapeResult.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clinic = scrapeResult.data.clinic;
|
const channelData = row.channel_data || {};
|
||||||
const resolvedName = clinicName || clinic.clinicName || url;
|
const analysisData = row.analysis_data || {};
|
||||||
const services = clinic.services || [];
|
const scrapeData = row.scrape_data || {};
|
||||||
const address = clinic.address || "";
|
const clinic = scrapeData.clinic || {};
|
||||||
|
const verified = row.verified_channels || {};
|
||||||
|
|
||||||
// Step 2: Call analyze-market function
|
// Build real data summary for AI prompt
|
||||||
const analyzeRes = await fetch(`${supabaseUrl}/functions/v1/analyze-market`, {
|
const channelSummary = buildChannelSummary(channelData, verified);
|
||||||
method: "POST",
|
const marketSummary = JSON.stringify(analysisData.analysis || {}, null, 2).slice(0, 8000);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${supabaseKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
clinicName: resolvedName,
|
|
||||||
services,
|
|
||||||
address,
|
|
||||||
scrapeData: scrapeResult.data,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const analyzeResult = await analyzeRes.json();
|
|
||||||
|
|
||||||
// Step 3: Generate final report with Gemini
|
|
||||||
const reportPrompt = `
|
const reportPrompt = `
|
||||||
당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요.
|
당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 **실제 수집된 데이터**를 기반으로 종합 마케팅 리포트를 생성해주세요.
|
||||||
|
|
||||||
## 수집된 데이터
|
⚠️ 중요: 아래 데이터에 없는 수치는 절대 추측하지 마세요. 데이터가 없으면 "데이터 없음"으로 표시하세요.
|
||||||
|
|
||||||
### 병원 정보
|
## 병원 기본 정보
|
||||||
${JSON.stringify(scrapeResult.data, null, 2)}
|
- 병원명: ${clinic.clinicName || row.clinic_name}
|
||||||
|
- 주소: ${clinic.address || ""}
|
||||||
|
- 전화: ${clinic.phone || ""}
|
||||||
|
- 시술: ${(clinic.services || []).join(", ")}
|
||||||
|
- 의료진: ${JSON.stringify(clinic.doctors || []).slice(0, 500)}
|
||||||
|
- 슬로건: ${clinic.slogan || ""}
|
||||||
|
|
||||||
### 시장 분석
|
## 실제 채널 데이터 (수집 완료)
|
||||||
${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
${channelSummary}
|
||||||
|
|
||||||
|
## 시장 분석 데이터
|
||||||
|
${marketSummary}
|
||||||
|
|
||||||
|
## 웹사이트 브랜딩
|
||||||
|
${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
||||||
|
|
||||||
## 리포트 형식 (반드시 아래 JSON 구조로 응답)
|
## 리포트 형식 (반드시 아래 JSON 구조로 응답)
|
||||||
|
|
||||||
{
|
{
|
||||||
"clinicInfo": {
|
"clinicInfo": {
|
||||||
"name": "병원명 (한국어)",
|
"name": "병원명 (한국어)",
|
||||||
"nameEn": "영문 병원명",
|
"nameEn": "영문 병원명",
|
||||||
"established": "개원년도 (예: 2005)",
|
"established": "개원년도",
|
||||||
"address": "주소",
|
"address": "주소",
|
||||||
"phone": "전화번호",
|
"phone": "전화번호",
|
||||||
"services": ["시술1", "시술2"],
|
"services": ["시술1", "시술2"],
|
||||||
"doctors": [{"name": "의사명", "specialty": "전문분야"}],
|
"doctors": [{"name": "의사명", "specialty": "전문분야"}]
|
||||||
"socialMedia": {
|
|
||||||
"instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"],
|
|
||||||
"youtube": "YouTube 채널 핸들 또는 URL",
|
|
||||||
"facebook": "Facebook 페이지명 또는 URL",
|
|
||||||
"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": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
|
"naverBlog": { "score": 0-100, "status": "active|inactive|not_found", "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": "개선 방안"}] },
|
"instagram": { "score": 0-100, "status": "active|inactive|not_found", "followers": 실제수치, "posts": 실제수치, "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": "개선 방안"}] },
|
"youtube": { "score": 0-100, "status": "active|inactive|not_found", "subscribers": 실제수치, "recommendation": "추천사항", "diagnosis": [{"issue": "문제", "severity": "critical|warning|good", "recommendation": "개선안"}] },
|
||||||
"naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
|
"naverPlace": { "score": 0-100, "rating": 실제수치, "reviews": 실제수치, "recommendation": "추천사항" },
|
||||||
"gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] },
|
"gangnamUnni": { "score": 0-100, "rating": 실제수치, "ratingScale": 10, "reviews": 실제수치, "status": "active|not_found", "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": "개선 방안"}] }
|
"website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "이름", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [], "mainCTA": "주요 CTA" }
|
||||||
},
|
},
|
||||||
"newChannelProposals": [
|
"newChannelProposals": [{ "channel": "채널명", "priority": "P0|P1|P2", "rationale": "근거" }],
|
||||||
{ "channel": "제안 채널명", "priority": "P0|P1|P2", "rationale": "채널 개설 근거" }
|
"competitors": [{ "name": "경쟁병원", "strengths": [], "weaknesses": [], "marketingChannels": [] }],
|
||||||
],
|
"keywords": { "primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}], "longTail": [{"keyword": "키워드"}] },
|
||||||
"competitors": [
|
"targetAudience": { "primary": { "ageRange": "", "gender": "", "interests": [], "channels": [] } },
|
||||||
{ "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] }
|
"brandIdentity": [{ "area": "영역", "asIs": "현재", "toBe": "개선" }],
|
||||||
],
|
"kpiTargets": [{ "metric": "지표명 (실제 수집된 수치 기반으로 현실적 목표 설정)", "current": "현재 실제 수치", "target3Month": "3개월 목표", "target12Month": "12개월 목표" }],
|
||||||
"keywords": {
|
"recommendations": [{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "효과" }],
|
||||||
"primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}],
|
"marketTrends": ["트렌드1"]
|
||||||
"longTail": [{"keyword": "롱테일 키워드", "monthlySearches": 0}]
|
|
||||||
},
|
|
||||||
"targetAudience": {
|
|
||||||
"primary": { "ageRange": "25-35", "gender": "female", "interests": ["관심사1"], "channels": ["채널1"] },
|
|
||||||
"secondary": { "ageRange": "35-45", "gender": "female", "interests": ["관심사1"], "channels": ["채널1"] }
|
|
||||||
},
|
|
||||||
"brandIdentity": [
|
|
||||||
{ "area": "로고 및 비주얼", "asIs": "현재 로고/비주얼 아이덴티티 상태", "toBe": "개선 방향" },
|
|
||||||
{ "area": "브랜드 메시지/슬로건", "asIs": "현재 메시지", "toBe": "제안 메시지" },
|
|
||||||
{ "area": "톤앤보이스", "asIs": "현재 커뮤니케이션 스타일", "toBe": "권장 스타일" },
|
|
||||||
{ "area": "채널 일관성", "asIs": "채널별 불일치 사항", "toBe": "통일 방안" },
|
|
||||||
{ "area": "해시태그/키워드", "asIs": "현재 해시태그 전략", "toBe": "최적화된 해시태그 세트" },
|
|
||||||
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
|
|
||||||
],
|
|
||||||
"kpiTargets": [
|
|
||||||
{ "metric": "외부 측정 가능한 지표만 포함 (종합 점수, Instagram 팔로워, YouTube 구독자/조회수, 네이버 블로그 검색 노출 수, 강남언니 리뷰 수, Google Maps 평점 등). 병원 내부에서만 알 수 있는 지표(상담 문의, 매출, 예약 수 등)는 절대 포함하지 마세요.", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
|
|
||||||
],
|
|
||||||
"recommendations": [
|
|
||||||
{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" }
|
|
||||||
],
|
|
||||||
"marketTrends": ["트렌드1", "트렌드2"]
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
|
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${PERPLEXITY_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: "sonar",
|
model: "sonar",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{ role: "system", content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Use Korean for text fields. 강남언니 rating is 10-point scale. Use ONLY the provided real data — never invent metrics." },
|
||||||
role: "system",
|
|
||||||
content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Always respond in Korean for text fields.",
|
|
||||||
},
|
|
||||||
{ role: "user", content: reportPrompt },
|
{ role: "user", content: reportPrompt },
|
||||||
],
|
],
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
|
|
@ -165,91 +121,227 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
||||||
|
|
||||||
const aiData = await aiRes.json();
|
const aiData = await aiRes.json();
|
||||||
let reportText = aiData.choices?.[0]?.message?.content || "";
|
let reportText = aiData.choices?.[0]?.message?.content || "";
|
||||||
// Strip markdown code blocks if present
|
|
||||||
const jsonMatch = reportText.match(/```(?:json)?\n?([\s\S]*?)```/);
|
const jsonMatch = reportText.match(/```(?:json)?\n?([\s\S]*?)```/);
|
||||||
if (jsonMatch) reportText = jsonMatch[1];
|
if (jsonMatch) reportText = jsonMatch[1];
|
||||||
|
|
||||||
let report;
|
let report;
|
||||||
try {
|
try { report = JSON.parse(reportText); } catch { report = { raw: reportText, parseError: true }; }
|
||||||
report = JSON.parse(reportText);
|
|
||||||
} catch {
|
// Embed channel enrichment data for frontend mergeEnrichment()
|
||||||
report = { raw: reportText, parseError: true };
|
report.channelEnrichment = channelData;
|
||||||
|
report.enrichedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Embed verified handles
|
||||||
|
const igHandles = (verified.instagram || []).filter((v: { verified: boolean }) => v.verified).map((v: { handle: string }) => v.handle);
|
||||||
|
report.socialHandles = {
|
||||||
|
instagram: igHandles.length > 0 ? igHandles : null,
|
||||||
|
youtube: verified.youtube?.verified ? verified.youtube.handle : null,
|
||||||
|
facebook: verified.facebook?.verified ? verified.facebook.handle : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await supabase.from("marketing_reports").update({
|
||||||
|
report,
|
||||||
|
status: "complete",
|
||||||
|
pipeline_completed_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}).eq("id", body.reportId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
reportId: body.reportId,
|
||||||
|
report,
|
||||||
|
metadata: {
|
||||||
|
url: row.url,
|
||||||
|
clinicName: row.clinic_name,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataSources: { scraping: true, marketAnalysis: true, aiGeneration: !report.parseError },
|
||||||
|
socialHandles: report.socialHandles,
|
||||||
|
address: clinic.address || "",
|
||||||
|
services: clinic.services || [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback)
|
// ─── V1 Legacy: url-based single-call flow (backwards compat) ───
|
||||||
|
const { url, clinicName } = body;
|
||||||
|
if (!url) {
|
||||||
|
return new Response(JSON.stringify({ error: "URL or reportId is required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call scrape-website
|
||||||
|
const scrapeRes = await fetch(`${supabaseUrl}/functions/v1/scrape-website`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${supabaseKey}` },
|
||||||
|
body: JSON.stringify({ url, clinicName }),
|
||||||
|
});
|
||||||
|
const scrapeResult = await scrapeRes.json();
|
||||||
|
if (!scrapeResult.success) throw new Error(`Scraping failed: ${scrapeResult.error}`);
|
||||||
|
|
||||||
|
const clinic = scrapeResult.data.clinic;
|
||||||
|
const resolvedName = clinicName || clinic.clinicName || url;
|
||||||
|
const services = clinic.services || [];
|
||||||
|
const address = clinic.address || "";
|
||||||
|
|
||||||
|
// Call analyze-market
|
||||||
|
const analyzeRes = await fetch(`${supabaseUrl}/functions/v1/analyze-market`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${supabaseKey}` },
|
||||||
|
body: JSON.stringify({ clinicName: resolvedName, services, address, scrapeData: scrapeResult.data }),
|
||||||
|
});
|
||||||
|
const analyzeResult = await analyzeRes.json();
|
||||||
|
|
||||||
|
// Generate report with Perplexity (legacy prompt)
|
||||||
|
const reportPrompt = `당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요.
|
||||||
|
|
||||||
|
## 수집된 데이터
|
||||||
|
### 병원 정보
|
||||||
|
${JSON.stringify(scrapeResult.data, null, 2).slice(0, 6000)}
|
||||||
|
|
||||||
|
### 시장 분석
|
||||||
|
${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2).slice(0, 4000)}
|
||||||
|
|
||||||
|
## 리포트 형식 (반드시 아래 JSON 구조로 응답)
|
||||||
|
{
|
||||||
|
"clinicInfo": { "name": "병원명", "nameEn": "영문명", "established": "개원년도", "address": "주소", "phone": "전화", "services": [], "doctors": [], "socialMedia": { "instagramAccounts": [], "youtube": "", "facebook": "", "naverBlog": "" } },
|
||||||
|
"executiveSummary": "요약", "overallScore": 0-100,
|
||||||
|
"channelAnalysis": { "naverBlog": { "score": 0-100, "status": "active|inactive", "recommendation": "" }, "instagram": { "score": 0-100, "followers": 0, "recommendation": "" }, "youtube": { "score": 0-100, "subscribers": 0, "recommendation": "" }, "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0 }, "gangnamUnni": { "score": 0-100, "rating": 0, "ratingScale": 10, "reviews": 0, "status": "active|not_found" }, "website": { "score": 0-100, "issues": [], "trackingPixels": [], "snsLinksOnSite": false, "mainCTA": "" } },
|
||||||
|
"brandIdentity": [{ "area": "", "asIs": "", "toBe": "" }],
|
||||||
|
"kpiTargets": [{ "metric": "", "current": "", "target3Month": "", "target12Month": "" }],
|
||||||
|
"recommendations": [{ "priority": "high|medium|low", "category": "", "title": "", "description": "", "expectedImpact": "" }],
|
||||||
|
"newChannelProposals": [{ "channel": "", "priority": "P0|P1|P2", "rationale": "" }],
|
||||||
|
"marketTrends": []
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const aiRes = await fetch("https://api.perplexity.ai/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "sonar",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Korean for text fields. 강남언니 rating uses 10-point scale." },
|
||||||
|
{ role: "user", content: reportPrompt },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiData = await aiRes.json();
|
||||||
|
let reportText = aiData.choices?.[0]?.message?.content || "";
|
||||||
|
const jsonMatch = reportText.match(/```(?:json)?\n?([\s\S]*?)```/);
|
||||||
|
if (jsonMatch) reportText = jsonMatch[1];
|
||||||
|
|
||||||
|
let report;
|
||||||
|
try { report = JSON.parse(reportText); } catch { report = { raw: reportText, parseError: true }; }
|
||||||
|
|
||||||
|
// Merge social handles
|
||||||
const scrapeSocial = clinic.socialMedia || {};
|
const scrapeSocial = clinic.socialMedia || {};
|
||||||
const aiSocial = report?.clinicInfo?.socialMedia || {};
|
const aiSocial = report?.clinicInfo?.socialMedia || {};
|
||||||
|
const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts) ? aiSocial.instagramAccounts : aiSocial.instagram ? [aiSocial.instagram] : [];
|
||||||
// Instagram: collect all accounts from AI + Firecrawl, deduplicate
|
|
||||||
const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts)
|
|
||||||
? aiSocial.instagramAccounts
|
|
||||||
: aiSocial.instagram ? [aiSocial.instagram] : [];
|
|
||||||
const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : [];
|
const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : [];
|
||||||
const allIgRaw = [...aiIgAccounts, ...scrapeIg];
|
const igHandles = [...new Set([...aiIgAccounts, ...scrapeIg].map((h: string) => normalizeInstagramHandle(h)).filter((h): h is string => h !== null))];
|
||||||
const igHandles = [...new Set(
|
|
||||||
allIgRaw
|
|
||||||
.map((h: string) => normalizeInstagramHandle(h))
|
|
||||||
.filter((h): h is string => h !== null)
|
|
||||||
)];
|
|
||||||
|
|
||||||
// Filter out empty strings — AI sometimes returns "" instead of null
|
|
||||||
const pickNonEmpty = (...vals: (string | null | undefined)[]): string | null =>
|
|
||||||
vals.find(v => v && v.trim().length > 0) || null;
|
|
||||||
|
|
||||||
|
const pickNonEmpty = (...vals: (string | null | undefined)[]): string | null => vals.find(v => v && v.trim().length > 0) || null;
|
||||||
const normalizedHandles = {
|
const normalizedHandles = {
|
||||||
instagram: igHandles.length > 0 ? igHandles : null,
|
instagram: igHandles.length > 0 ? igHandles : null,
|
||||||
youtube: pickNonEmpty(aiSocial.youtube, scrapeSocial.youtube),
|
youtube: pickNonEmpty(aiSocial.youtube, scrapeSocial.youtube),
|
||||||
facebook: pickNonEmpty(aiSocial.facebook, scrapeSocial.facebook),
|
facebook: pickNonEmpty(aiSocial.facebook, scrapeSocial.facebook),
|
||||||
blog: pickNonEmpty(aiSocial.naverBlog, scrapeSocial.blog),
|
blog: pickNonEmpty(aiSocial.naverBlog, scrapeSocial.blog),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Embed normalized handles in report for DB persistence
|
|
||||||
report.socialHandles = normalizedHandles;
|
report.socialHandles = normalizedHandles;
|
||||||
|
|
||||||
// Save to Supabase
|
// Save
|
||||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
const { data: saved, error: saveError } = await supabase.from("marketing_reports").insert({
|
||||||
const { data: saved, error: saveError } = await supabase
|
url, clinic_name: resolvedName, report, scrape_data: scrapeResult.data, analysis_data: analyzeResult.data, status: "complete",
|
||||||
.from("marketing_reports")
|
}).select("id").single();
|
||||||
.insert({
|
|
||||||
url,
|
|
||||||
clinic_name: resolvedName,
|
|
||||||
report,
|
|
||||||
scrape_data: scrapeResult.data,
|
|
||||||
analysis_data: analyzeResult.data,
|
|
||||||
})
|
|
||||||
.select("id")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (saveError) {
|
if (saveError) console.error("DB save error:", saveError);
|
||||||
console.error("DB save error:", saveError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true, reportId: saved?.id || null, report,
|
||||||
reportId: saved?.id || null,
|
metadata: { url, clinicName: resolvedName, generatedAt: new Date().toISOString(), dataSources: { scraping: scrapeResult.success, marketAnalysis: analyzeResult.success, aiGeneration: !report.parseError }, socialHandles: normalizedHandles, saveError: saveError?.message || null, address, services },
|
||||||
report,
|
|
||||||
metadata: {
|
|
||||||
url,
|
|
||||||
clinicName: resolvedName,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
dataSources: {
|
|
||||||
scraping: scrapeResult.success,
|
|
||||||
marketAnalysis: analyzeResult.success,
|
|
||||||
aiGeneration: !report.parseError,
|
|
||||||
},
|
|
||||||
socialHandles: normalizedHandles,
|
|
||||||
saveError: saveError?.message || null,
|
|
||||||
address,
|
|
||||||
services,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: false, error: error.message }),
|
JSON.stringify({ success: false, error: error.message }),
|
||||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Helper: Build channel summary from collected data ───
|
||||||
|
|
||||||
|
function buildChannelSummary(channelData: Record<string, unknown>, verified: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Instagram
|
||||||
|
const igAccounts = channelData.instagramAccounts as Record<string, unknown>[] | undefined;
|
||||||
|
if (igAccounts?.length) {
|
||||||
|
for (const ig of igAccounts) {
|
||||||
|
parts.push(`### Instagram @${ig.username}`);
|
||||||
|
parts.push(`- 팔로워: ${(ig.followers as number || 0).toLocaleString()}명, 게시물: ${ig.posts}개`);
|
||||||
|
parts.push(`- 비즈니스 계정: ${ig.isBusinessAccount ? 'O' : 'X'}`);
|
||||||
|
parts.push(`- Bio: ${(ig.bio as string || '').slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push("### Instagram: 데이터 없음");
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube
|
||||||
|
const yt = channelData.youtube as Record<string, unknown> | undefined;
|
||||||
|
if (yt) {
|
||||||
|
parts.push(`### YouTube ${yt.handle || yt.channelName}`);
|
||||||
|
parts.push(`- 구독자: ${(yt.subscribers as number || 0).toLocaleString()}명, 영상: ${yt.totalVideos}개, 총 조회수: ${(yt.totalViews as number || 0).toLocaleString()}`);
|
||||||
|
parts.push(`- 채널 설명: ${(yt.description as string || '').slice(0, 300)}`);
|
||||||
|
const videos = yt.videos as Record<string, unknown>[] | undefined;
|
||||||
|
if (videos?.length) {
|
||||||
|
parts.push(`- 인기 영상 TOP ${videos.length}:`);
|
||||||
|
for (const v of videos.slice(0, 5)) {
|
||||||
|
parts.push(` - "${v.title}" (조회수: ${(v.views as number || 0).toLocaleString()}, 좋아요: ${v.likes})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push("### YouTube: 데이터 없음");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facebook
|
||||||
|
const fb = channelData.facebook as Record<string, unknown> | undefined;
|
||||||
|
if (fb) {
|
||||||
|
parts.push(`### Facebook ${fb.pageName}`);
|
||||||
|
parts.push(`- 팔로워: ${(fb.followers as number || 0).toLocaleString()}, 좋아요: ${fb.likes}`);
|
||||||
|
parts.push(`- 소개: ${(fb.intro as string || '').slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강남언니
|
||||||
|
const gu = channelData.gangnamUnni as Record<string, unknown> | undefined;
|
||||||
|
if (gu) {
|
||||||
|
parts.push(`### 강남언니 ${gu.name}`);
|
||||||
|
parts.push(`- 평점: ${gu.rating}/10, 리뷰: ${(gu.totalReviews as number || 0).toLocaleString()}건`);
|
||||||
|
const doctors = gu.doctors as Record<string, unknown>[] | undefined;
|
||||||
|
if (doctors?.length) {
|
||||||
|
parts.push(`- 등록 의사: ${doctors.map(d => `${d.name}(${d.specialty})`).join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Maps
|
||||||
|
const gm = channelData.googleMaps as Record<string, unknown> | undefined;
|
||||||
|
if (gm) {
|
||||||
|
parts.push(`### Google Maps ${gm.name}`);
|
||||||
|
parts.push(`- 평점: ${gm.rating}/5, 리뷰: ${gm.reviewCount}건`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naver
|
||||||
|
const nb = channelData.naverBlog as Record<string, unknown> | undefined;
|
||||||
|
if (nb) {
|
||||||
|
parts.push(`### 네이버 블로그: 검색결과 ${nb.totalResults}건`);
|
||||||
|
}
|
||||||
|
const np = channelData.naverPlace as Record<string, unknown> | undefined;
|
||||||
|
if (np) {
|
||||||
|
parts.push(`### 네이버 플레이스: ${np.name} (${np.category})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Pipeline V2: Add columns for 3-phase analysis pipeline
|
||||||
|
-- Phase 1 (discover-channels) → Phase 2 (collect-channel-data) → Phase 3 (generate-report)
|
||||||
|
|
||||||
|
ALTER TABLE marketing_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS verified_channels JSONB DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS channel_data JSONB DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS pipeline_started_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS pipeline_completed_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||||
|
|
||||||
|
-- Mark all existing rows as complete (they were generated by the old pipeline)
|
||||||
|
UPDATE marketing_reports SET status = 'complete' WHERE status IS NULL OR status = 'pending';
|
||||||
|
|
||||||
|
-- Index for frontend polling
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_marketing_reports_status ON marketing_reports(id, status);
|
||||||
Loading…
Reference in New Issue