From 7557ef774c06127b652556d9be87db8e0d3fe6b6 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 3 Apr 2026 21:49:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Pipeline=20V2=20=E2=80=94=203-phase=20a?= =?UTF-8?q?nalysis=20with=20verified=20channel=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - Loading text: 20-30초 → 1-2분 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/report/ClinicSnapshot.tsx | 2 +- src/components/report/KPIDashboard.tsx | 9 +- src/hooks/useReport.ts | 21 +- src/lib/supabase.ts | 64 +++ src/lib/transformReport.ts | 14 +- src/pages/AnalysisLoadingPage.tsx | 191 +++++--- src/pages/ClinicProfilePage.tsx | 7 +- .../functions/_shared/extractSocialLinks.ts | 134 ++++++ supabase/functions/_shared/verifyHandles.ts | 236 ++++++++++ .../functions/collect-channel-data/index.ts | 329 ++++++++++++++ supabase/functions/discover-channels/index.ts | 269 +++++++++++ supabase/functions/enrich-channels/index.ts | 47 +- supabase/functions/generate-report/index.ts | 420 +++++++++++------- supabase/migrations/20260403_pipeline_v2.sql | 16 + 14 files changed, 1506 insertions(+), 253 deletions(-) create mode 100644 supabase/functions/_shared/extractSocialLinks.ts create mode 100644 supabase/functions/_shared/verifyHandles.ts create mode 100644 supabase/functions/collect-channel-data/index.ts create mode 100644 supabase/functions/discover-channels/index.ts create mode 100644 supabase/migrations/20260403_pipeline_v2.sql diff --git a/src/components/report/ClinicSnapshot.tsx b/src/components/report/ClinicSnapshot.tsx index 3d50920..86e312a 100644 --- a/src/components/report/ClinicSnapshot.tsx +++ b/src/components/report/ClinicSnapshot.tsx @@ -14,7 +14,7 @@ function formatNumber(n: number): string { const infoFields = (data: ClinicSnapshotType) => [ data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : 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.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, diff --git a/src/components/report/KPIDashboard.tsx b/src/components/report/KPIDashboard.tsx index c11940e..4d595b0 100644 --- a/src/components/report/KPIDashboard.tsx +++ b/src/components/report/KPIDashboard.tsx @@ -1,5 +1,5 @@ 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 { SectionWrapper } from './ui/SectionWrapper'; import { useExportPDF } from '../../hooks/useExportPDF'; @@ -30,6 +30,7 @@ function formatKpiValue(value: string): string { export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const { exportPDF, isExporting } = useExportPDF(); return ( @@ -94,13 +95,13 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.

- 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" > 마케팅 기획 - + ) : ( <> -
- {steps.map((step, index) => { - const isCompleted = currentStep > index; - const isActive = currentStep === index + 1 && currentStep <= steps.length; + {/* Pipeline steps */} +
+ {PHASE_STEPS.map((step, index) => { + const isCompleted = phaseIndex > index; + const isActive = phaseIndex === index && phase !== 'complete'; + const isDone = step.key === 'complete' && phase === 'complete'; return (
- {isCompleted ? ( + {isCompleted || isDone ? ( )}
- - {step.label} + + {isCompleted ? step.labelDone : step.label}
); })}
+ {/* Verified channels panel — shows after Phase 1 */} + + {verifiedChannels && ( + +

+ 발견된 채널 +

+
+ {Object.entries(verifiedChannels).map(([key, value]) => { + if (!value) return null; + const channels = Array.isArray(value) ? value : [value]; + return channels.map((ch, i) => ( +
+ {ch.verified ? ( + + ) : ( + + )} + + {CHANNEL_LABELS[key] || key} + {ch.handle && key !== 'gangnamUnni' ? ` @${ch.handle}` : ''} + + + {ch.verified ? 'verified' : 'not found'} + +
+ )); + })} +
+
+ )} +
+ + {/* Progress bar */}

- AI가 마케팅 데이터를 분석하고 있습니다. 약 20~30초 소요됩니다. + AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.

)} diff --git a/src/pages/ClinicProfilePage.tsx b/src/pages/ClinicProfilePage.tsx index 707e1e4..ee1fb88 100644 --- a/src/pages/ClinicProfilePage.tsx +++ b/src/pages/ClinicProfilePage.tsx @@ -153,7 +153,12 @@ export default function ClinicProfilePage() { const chAnalysis = report.channelAnalysis as Record> | undefined; if (chAnalysis) { 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 }); } }) diff --git a/supabase/functions/_shared/extractSocialLinks.ts b/supabase/functions/_shared/extractSocialLinks.ts new file mode 100644 index 0000000..ea45784 --- /dev/null +++ b/supabase/functions/_shared/extractSocialLinks.ts @@ -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> = {}; + 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 { + 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; +} diff --git a/supabase/functions/_shared/verifyHandles.ts b/supabase/functions/_shared/verifyHandles.ts new file mode 100644 index 0000000..c9a15f2 --- /dev/null +++ b/supabase/functions/_shared/verifyHandles.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) => 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 { + const YOUTUBE_API_KEY = Deno.env.get('YOUTUBE_API_KEY') || ''; + const FIRECRAWL_API_KEY = Deno.env.get('FIRECRAWL_API_KEY') || ''; + + const tasks: Promise[] = []; + 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; +} diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts new file mode 100644 index 0000000..0aaf78c --- /dev/null +++ b/supabase/functions/collect-channel-data/index.ts @@ -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, token: string): Promise { + 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 = {}; + const analysisData: Record = {}; + const tasks: Promise[] = []; + + // ─── 1. Instagram (multi-account) ─── + if (APIFY_TOKEN && verified.instagram?.length > 0) { + tasks.push((async () => { + const accounts: Record[] = []; + 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[])[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[]) || []).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) => (i.id as Record)?.videoId).filter(Boolean).join(","); + + let videos: Record[] = []; + 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 || {}; + const vSnip = v.snippet as Record || {}; + const vCon = v.contentDetails as Record || {}; + 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>)?.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[])[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) => ({ + 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[]).length > 0) break; + } + const place = (items as Record[])[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[]) || []).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" } }, + ); + } +}); diff --git a/supabase/functions/discover-channels/index.ts b/supabase/functions/discover-channels/index.ts new file mode 100644 index 0000000..25b05d9 --- /dev/null +++ b/supabase/functions/discover-channels/index.ts @@ -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 = { + 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 = {}; + 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" } }, + ); + } +}); diff --git a/supabase/functions/enrich-channels/index.ts b/supabase/functions/enrich-channels/index.ts index edc8eae..30b3437 100644 --- a/supabase/functions/enrich-channels/index.ts +++ b/supabase/functions/enrich-channels/index.ts @@ -107,8 +107,10 @@ Deno.serve(async (req) => { bio: profile.biography, isBusinessAccount: profile.isBusinessAccount, externalUrl: profile.externalUrl, + igtvVideoCount: profile.igtvVideoCount, + highlightsCount: profile.highlightsCount, latestPosts: ((profile.latestPosts as Record[]) || []) - .slice(0, 6) + .slice(0, 12) .map((p) => ({ type: p.type, likes: p.likesCount, @@ -189,21 +191,34 @@ Deno.serve(async (req) => { tasks.push( (async () => { // Step 1: Search for the clinic's gangnamunni page - const searchRes = await fetch("https://api.firecrawl.dev/v1/search", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${FIRECRAWL_API_KEY}`, - }, - body: JSON.stringify({ - query: `${clinicName} site:gangnamunni.com`, - limit: 3, - }), - }); - const searchData = await searchRes.json(); - const hospitalUrl = (searchData.data || []) - .map((r: Record) => r.url) - .find((u: string) => u?.includes("gangnamunni.com/hospitals/")); + // 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", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify({ query, limit: 5 }), + }); + const searchData = await searchRes.json(); + hospitalUrl = (searchData.data || []) + .map((r: Record) => r.url) + .find((u: string) => u?.includes("gangnamunni.com/hospitals/")); + } catch { + // Try next query + } + } if (!hospitalUrl) return; diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts index 321b69c..7ffc61b 100644 --- a/supabase/functions/generate-report/index.ts +++ b/supabase/functions/generate-report/index.ts @@ -9,7 +9,10 @@ const corsHeaders = { }; 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; } @@ -19,144 +22,204 @@ Deno.serve(async (req) => { } try { - const { url, clinicName } = (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 body = (await req.json()) as ReportRequest; const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY"); - if (!PERPLEXITY_API_KEY) { - throw new Error("PERPLEXITY_API_KEY not configured"); - } + if (!PERPLEXITY_API_KEY) throw new Error("PERPLEXITY_API_KEY not configured"); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; 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) ─── + if (body.reportId) { + const { data: row, error: fetchErr } = await supabase + .from("marketing_reports") + .select("*") + .eq("id", body.reportId) + .single(); + if (fetchErr || !row) throw new Error(`Report not found: ${fetchErr?.message}`); + + await supabase.from("marketing_reports").update({ status: "generating" }).eq("id", body.reportId); + + const channelData = row.channel_data || {}; + const analysisData = row.analysis_data || {}; + const scrapeData = row.scrape_data || {}; + const clinic = scrapeData.clinic || {}; + const verified = row.verified_channels || {}; + + // Build real data summary for AI prompt + const channelSummary = buildChannelSummary(channelData, verified); + const marketSummary = JSON.stringify(analysisData.analysis || {}, null, 2).slice(0, 8000); + + const reportPrompt = ` +당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 **실제 수집된 데이터**를 기반으로 종합 마케팅 리포트를 생성해주세요. + +⚠️ 중요: 아래 데이터에 없는 수치는 절대 추측하지 마세요. 데이터가 없으면 "데이터 없음"으로 표시하세요. + +## 병원 기본 정보 +- 병원명: ${clinic.clinicName || row.clinic_name} +- 주소: ${clinic.address || ""} +- 전화: ${clinic.phone || ""} +- 시술: ${(clinic.services || []).join(", ")} +- 의료진: ${JSON.stringify(clinic.doctors || []).slice(0, 500)} +- 슬로건: ${clinic.slogan || ""} + +## 실제 채널 데이터 (수집 완료) +${channelSummary} + +## 시장 분석 데이터 +${marketSummary} + +## 웹사이트 브랜딩 +${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} + +## 리포트 형식 (반드시 아래 JSON 구조로 응답) +{ + "clinicInfo": { + "name": "병원명 (한국어)", + "nameEn": "영문 병원명", + "established": "개원년도", + "address": "주소", + "phone": "전화번호", + "services": ["시술1", "시술2"], + "doctors": [{"name": "의사명", "specialty": "전문분야"}] + }, + "executiveSummary": "경영진 요약 (3-5문장)", + "overallScore": 0-100, + "channelAnalysis": { + "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|not_found", "followers": 실제수치, "posts": 실제수치, "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": 실제수치, "reviews": 실제수치, "recommendation": "추천사항" }, + "gangnamUnni": { "score": 0-100, "rating": 실제수치, "ratingScale": 10, "reviews": 실제수치, "status": "active|not_found", "recommendation": "추천사항" }, + "website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "이름", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [], "mainCTA": "주요 CTA" } + }, + "newChannelProposals": [{ "channel": "채널명", "priority": "P0|P1|P2", "rationale": "근거" }], + "competitors": [{ "name": "경쟁병원", "strengths": [], "weaknesses": [], "marketingChannels": [] }], + "keywords": { "primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}], "longTail": [{"keyword": "키워드"}] }, + "targetAudience": { "primary": { "ageRange": "", "gender": "", "interests": [], "channels": [] } }, + "brandIdentity": [{ "area": "영역", "asIs": "현재", "toBe": "개선" }], + "kpiTargets": [{ "metric": "지표명 (실제 수집된 수치 기반으로 현실적 목표 설정)", "current": "현재 실제 수치", "target3Month": "3개월 목표", "target12Month": "12개월 목표" }], + "recommendations": [{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "효과" }], + "marketTrends": ["트렌드1"] +} +`; + + 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. Use Korean for text fields. 강남언니 rating is 10-point scale. Use ONLY the provided real data — never invent metrics." }, + { 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 }; } + + // Embed channel enrichment data for frontend mergeEnrichment() + 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" } }, + ); + } + + // ─── 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}`, - }, + 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}`); - } + 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 || ""; - // Step 2: Call analyze-market function + // 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, - }), + 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(); - // Step 3: Generate final report with Gemini - const reportPrompt = ` -당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요. + // Generate report with Perplexity (legacy prompt) + const reportPrompt = `당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요. ## 수집된 데이터 - ### 병원 정보 -${JSON.stringify(scrapeResult.data, null, 2)} +${JSON.stringify(scrapeResult.data, null, 2).slice(0, 6000)} ### 시장 분석 -${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)} +${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2).slice(0, 4000)} ## 리포트 형식 (반드시 아래 JSON 구조로 응답) - { - "clinicInfo": { - "name": "병원명 (한국어)", - "nameEn": "영문 병원명", - "established": "개원년도 (예: 2005)", - "address": "주소", - "phone": "전화번호", - "services": ["시술1", "시술2"], - "doctors": [{"name": "의사명", "specialty": "전문분야"}], - "socialMedia": { - "instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"], - "youtube": "YouTube 채널 핸들 또는 URL", - "facebook": "Facebook 페이지명 또는 URL", - "naverBlog": "네이버 블로그 ID" - } - }, - "executiveSummary": "경영진 요약 (3-5문장)", - "overallScore": 0-100, - "channelAnalysis": { - "naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, - "instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, - "youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, - "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, - "gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, - "website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "Google Analytics|Facebook Pixel|Naver Analytics|etc", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [{"domain": "example.com", "purpose": "용도"}], "mainCTA": "주요 전환 유도 요소", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] } - }, - "newChannelProposals": [ - { "channel": "제안 채널명", "priority": "P0|P1|P2", "rationale": "채널 개설 근거" } - ], - "competitors": [ - { "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] } - ], - "keywords": { - "primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}], - "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"] -} -`; + "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}`, - }, + 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. Always respond in Korean for text fields.", - }, + { 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, @@ -165,91 +228,120 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)} const aiData = await aiRes.json(); let reportText = aiData.choices?.[0]?.message?.content || ""; - // Strip markdown code blocks if present 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 }; - } + try { report = JSON.parse(reportText); } catch { report = { raw: reportText, parseError: true }; } - // Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback) + // Merge social handles const scrapeSocial = clinic.socialMedia || {}; const aiSocial = report?.clinicInfo?.socialMedia || {}; - - // Instagram: collect all accounts from AI + Firecrawl, deduplicate - const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts) - ? aiSocial.instagramAccounts - : aiSocial.instagram ? [aiSocial.instagram] : []; + const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts) ? aiSocial.instagramAccounts : aiSocial.instagram ? [aiSocial.instagram] : []; const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : []; - const allIgRaw = [...aiIgAccounts, ...scrapeIg]; - 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 igHandles = [...new Set([...aiIgAccounts, ...scrapeIg].map((h: string) => normalizeInstagramHandle(h)).filter((h): h is string => h !== null))]; + const pickNonEmpty = (...vals: (string | null | undefined)[]): string | null => vals.find(v => v && v.trim().length > 0) || null; const normalizedHandles = { instagram: igHandles.length > 0 ? igHandles : null, youtube: pickNonEmpty(aiSocial.youtube, scrapeSocial.youtube), facebook: pickNonEmpty(aiSocial.facebook, scrapeSocial.facebook), blog: pickNonEmpty(aiSocial.naverBlog, scrapeSocial.blog), }; - - // Embed normalized handles in report for DB persistence report.socialHandles = normalizedHandles; - // Save to Supabase - const supabase = createClient(supabaseUrl, supabaseKey); - const { data: saved, error: saveError } = await supabase - .from("marketing_reports") - .insert({ - url, - clinic_name: resolvedName, - report, - scrape_data: scrapeResult.data, - analysis_data: analyzeResult.data, - }) - .select("id") - .single(); + // Save + const { data: saved, error: saveError } = await supabase.from("marketing_reports").insert({ + url, clinic_name: resolvedName, report, scrape_data: scrapeResult.data, analysis_data: analyzeResult.data, status: "complete", + }).select("id").single(); - if (saveError) { - console.error("DB save error:", saveError); - } + if (saveError) console.error("DB save error:", saveError); return new Response( JSON.stringify({ - success: true, - reportId: saved?.id || null, - 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, - }, + success: true, reportId: saved?.id || null, 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) { return new Response( 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, verified: Record): string { + const parts: string[] = []; + + // Instagram + const igAccounts = channelData.instagramAccounts as Record[] | 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 | 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[] | 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 | 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 | undefined; + if (gu) { + parts.push(`### 강남언니 ${gu.name}`); + parts.push(`- 평점: ${gu.rating}/10, 리뷰: ${(gu.totalReviews as number || 0).toLocaleString()}건`); + const doctors = gu.doctors as Record[] | undefined; + if (doctors?.length) { + parts.push(`- 등록 의사: ${doctors.map(d => `${d.name}(${d.specialty})`).join(', ')}`); + } + } + + // Google Maps + const gm = channelData.googleMaps as Record | undefined; + if (gm) { + parts.push(`### Google Maps ${gm.name}`); + parts.push(`- 평점: ${gm.rating}/5, 리뷰: ${gm.reviewCount}건`); + } + + // Naver + const nb = channelData.naverBlog as Record | undefined; + if (nb) { + parts.push(`### 네이버 블로그: 검색결과 ${nb.totalResults}건`); + } + const np = channelData.naverPlace as Record | undefined; + if (np) { + parts.push(`### 네이버 플레이스: ${np.name} (${np.category})`); + } + + return parts.join("\n"); +} diff --git a/supabase/migrations/20260403_pipeline_v2.sql b/supabase/migrations/20260403_pipeline_v2.sql new file mode 100644 index 0000000..6553b4a --- /dev/null +++ b/supabase/migrations/20260403_pipeline_v2.sql @@ -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);