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);