diff --git a/.claude/launch.json b/.claude/launch.json
index 4ea8f9d..2fc021c 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -3,8 +3,9 @@
"configurations": [
{
"name": "infinith-dev",
- "runtimeExecutable": "npm",
+ "runtimeExecutable": "/opt/homebrew/bin/npm",
"runtimeArgs": ["run", "dev"],
+ "env": { "PATH": "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/bin:/bin" },
"port": 3000
}
]
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 125e96b..d8dfc76 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -13,7 +13,14 @@
"WebFetch(domain:socialblade.com)",
"WebFetch(domain:www.facebook.com)",
"mcp__mcp-registry__search_mcp_registry",
- "Bash(npx tsc:*)"
+ "Bash(npx tsc:*)",
+ "mcp__Claude_Preview__preview_start",
+ "Bash(export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\")",
+ "Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
+ "Bash(npx vite:*)",
+ "Bash(vercel --version)",
+ "Bash(find '/Users/haewonkam/Claude/Agentic Marketing/INFINITH' -name *.plan -o -name PLAN* -o -name plan* -o -name TODO* -o -name ROADMAP*)",
+ "WebFetch(domain:api.apify.com)"
]
}
}
diff --git a/.gitignore b/.gitignore
index ac3f474..7977a30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ coverage/
.env*
!.env.example
.vercel
+doc/
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 0000000..feead5b
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1 @@
+reference/
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..74baffc
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["denoland.vscode-deno"]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..af62c23
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,24 @@
+{
+ "deno.enablePaths": [
+ "supabase/functions"
+ ],
+ "deno.lint": true,
+ "deno.unstable": [
+ "bare-node-builtins",
+ "byonm",
+ "sloppy-imports",
+ "unsafe-proto",
+ "webgpu",
+ "broadcast-channel",
+ "worker-options",
+ "cron",
+ "kv",
+ "ffi",
+ "fs",
+ "http",
+ "net"
+ ],
+ "[typescript]": {
+ "editor.defaultFormatter": "denoland.vscode-deno"
+ }
+}
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index 41b211b..0602ea9 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -17,7 +17,7 @@ export default function Hero() {
{/* Background Gradient */}
-
+
Marketing that learns, improves, and accelerates — automatically.
- 쓸수록 더 정교해지는 AI 마케팅 엔진. 콘텐츠 기획, 생성, 영상 제작, 채널 배포, 데이터 분석까지 하나로.
+ 쓸수록 더 정교해지는 {' '}AI 마케팅 엔진. {' '}콘텐츠 기획, {' '}생성, {' '}영상 제작, {' '}채널 배포, {' '}데이터 분석까지 {' '}하나로.
-
+
Analyze Marketing Performance
@@ -74,10 +74,12 @@ export default function Hero() {
- {/* Decorative elements */}
-
-
-
+ {/* Decorative elements — isolate prevents mix-blend-multiply from bleeding into content */}
+
);
}
diff --git a/src/components/icons/FilledIcons.tsx b/src/components/icons/FilledIcons.tsx
index fc9bf5f..a5c504b 100644
--- a/src/components/icons/FilledIcons.tsx
+++ b/src/components/icons/FilledIcons.tsx
@@ -134,6 +134,148 @@ export function MusicFilled({ size = 20, className = '' }: IconProps) {
);
}
+/* ─── Dashboard / Utility Icons ─── */
+
+export function ShieldFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function DatabaseFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function ServerFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export function BoltFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function EyeFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function EyeOffFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function CopyFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function CheckFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function CrossFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function WarningFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function RefreshFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function FlowFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function CoinFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+ $
+
+ );
+}
+
+export function LinkExternalFilled({ size = 20, className = '' }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
/**
* InfinityLoopFilled — Infinite Marketing loop icon.
* HubSpot-style infinity loop with gradient shading.
diff --git a/src/components/report/KPIDashboard.tsx b/src/components/report/KPIDashboard.tsx
index 4d595b0..605dba6 100644
--- a/src/components/report/KPIDashboard.tsx
+++ b/src/components/report/KPIDashboard.tsx
@@ -10,22 +10,23 @@ interface KPIDashboardProps {
clinicName?: string;
}
-function isNegativeValue(value: string): boolean {
- const lower = value.toLowerCase();
+function isNegativeValue(value: string | number): boolean {
+ const lower = String(value).toLowerCase();
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a' || lower === '-' || lower.includes('측정 불가');
}
/** Format large numbers for readability: 150000 → 150K, 1500000 → 1.5M */
-function formatKpiValue(value: string): string {
+function formatKpiValue(value: string | number): string {
+ const str = String(value ?? '');
// If already formatted (contains K, M, %, ~, 월, 건, etc.) return as-is
- if (/[KkMm%~월건회개명/]/.test(value)) return value;
+ if (/[KkMm%~월건회개명/]/.test(str)) return str;
// Try to parse as pure number
- const num = parseInt(value.replace(/,/g, ''), 10);
- if (isNaN(num)) return value;
+ const num = parseInt(str.replace(/,/g, ''), 10);
+ if (isNaN(num)) return str;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 10_000) return `${Math.round(num / 1_000)}K`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
- return value;
+ return str;
}
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
diff --git a/src/components/report/ProblemDiagnosis.tsx b/src/components/report/ProblemDiagnosis.tsx
index 2750da9..93841ad 100644
--- a/src/components/report/ProblemDiagnosis.tsx
+++ b/src/components/report/ProblemDiagnosis.tsx
@@ -23,8 +23,8 @@ function clusterDiagnosis(items: DiagnosisItem[]): {
const funnelItems: string[] = [];
for (const item of items) {
- const cat = item.category.toLowerCase();
- const det = item.detail.toLowerCase();
+ const cat = String(item.category ?? '').toLowerCase();
+ const det = String(item.detail ?? '').toLowerCase();
// Brand identity / consistency issues
if (
@@ -91,6 +91,9 @@ function clusterDiagnosis(items: DiagnosisItem[]): {
}
export default function ProblemDiagnosis({ diagnosis }: ProblemDiagnosisProps) {
+ if (!diagnosis || !Array.isArray(diagnosis) || diagnosis.length === 0) {
+ return null;
+ }
const clusters = clusterDiagnosis(diagnosis);
return (
diff --git a/src/components/report/ui/SectionErrorBoundary.tsx b/src/components/report/ui/SectionErrorBoundary.tsx
new file mode 100644
index 0000000..91fad50
--- /dev/null
+++ b/src/components/report/ui/SectionErrorBoundary.tsx
@@ -0,0 +1,33 @@
+import { Component, type ReactNode } from 'react';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+}
+
+export class SectionErrorBoundary extends Component {
+ state: State = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error) {
+ console.error('[SectionErrorBoundary]', error.message, error.stack);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback ?? (
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts
index edf6d4c..41c2853 100644
--- a/src/lib/transformReport.ts
+++ b/src/lib/transformReport.ts
@@ -124,10 +124,12 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
// AI-generated per-channel diagnosis array (new format)
if (ch.diagnosis) {
for (const d of ch.diagnosis) {
- if (d.issue) {
+ const issue = typeof d.issue === 'string' ? d.issue : typeof d === 'string' ? d : JSON.stringify(d.issue ?? d);
+ if (issue) {
+ const rec = typeof d.recommendation === 'string' ? d.recommendation : '';
items.push({
category: CHANNEL_ICONS[channel] ? channel : channel,
- detail: d.recommendation ? `${d.issue} — ${d.recommendation}` : d.issue,
+ detail: rec ? `${issue} — ${rec}` : issue,
severity: (d.severity as Severity) || 'warning',
});
}
@@ -143,7 +145,7 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
}
if (ch.issues) {
for (const issue of ch.issues) {
- items.push({ category: channel, detail: issue, severity: 'warning' });
+ items.push({ category: channel, detail: typeof issue === 'string' ? issue : JSON.stringify(issue), severity: 'warning' });
}
}
}
@@ -274,6 +276,13 @@ function buildTransformation(r: ApiReport): import('../types/report').Transforma
return { brandIdentity, contentStrategy, platformStrategies, websiteImprovements, newChannelProposals };
}
+/** Safely format KPI values — handles numbers, strings, and nulls from AI output */
+function fmtKpi(v: unknown): string {
+ if (v == null) return '-';
+ if (typeof v === 'number') return fmt(v);
+ return String(v);
+}
+
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
@@ -468,15 +477,15 @@ function buildKpiDashboard(r: ApiReport): import('../types/report').KPIMetric[]
// Use AI's gangnamunni data — update existing or add
const guIdx = metrics.findIndex(m => m.metric.includes('강남언니'));
if (guIdx >= 0) {
- metrics[guIdx] = { metric: k.metric, current: k.current || '-', target3Month: k.target3Month || '-', target12Month: k.target12Month || '-' };
+ metrics[guIdx] = { metric: k.metric, current: fmtKpi(k.current), target3Month: fmtKpi(k.target3Month), target12Month: fmtKpi(k.target12Month) };
continue;
}
}
metrics.push({
metric: k.metric,
- current: k.current || '-',
- target3Month: k.target3Month || '-',
- target12Month: k.target12Month || '-',
+ current: fmtKpi(k.current),
+ target3Month: fmtKpi(k.target3Month),
+ target12Month: fmtKpi(k.target12Month),
});
}
}
diff --git a/src/main.tsx b/src/main.tsx
index 3e06fb3..6517381 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -12,6 +12,7 @@ import DistributionPage from './pages/DistributionPage.tsx';
import PerformancePage from './pages/PerformancePage.tsx';
import DataValidationPage from './pages/DataValidationPage.tsx';
import ClinicProfilePage from './pages/ClinicProfilePage.tsx';
+import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
@@ -30,6 +31,7 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />
diff --git a/src/pages/ApiDashboardPage.tsx b/src/pages/ApiDashboardPage.tsx
new file mode 100644
index 0000000..07d99cc
--- /dev/null
+++ b/src/pages/ApiDashboardPage.tsx
@@ -0,0 +1,621 @@
+import { useState, useEffect, type ReactNode } from 'react';
+import { motion } from 'motion/react';
+import { ArrowRight } from 'lucide-react';
+import {
+ ShieldFilled, DatabaseFilled, ServerFilled, BoltFilled,
+ EyeFilled, EyeOffFilled, CopyFilled, CheckFilled, CrossFilled,
+ WarningFilled, RefreshFilled, FlowFilled, CoinFilled,
+ LinkExternalFilled, GlobeFilled, PrismFilled,
+ YoutubeFilled, VideoFilled, MegaphoneFilled,
+} from '../components/icons/FilledIcons';
+
+/* ───────────────────────────── Types ───────────────────────────── */
+
+type ApiStatus = 'connected' | 'missing' | 'error';
+type ApiLayer = 'frontend' | 'edge-function' | 'both';
+
+interface ApiConfig {
+ id: string;
+ name: string;
+ description: string;
+ envKeys: string[];
+ layer: ApiLayer;
+ docsUrl: string;
+ IconComponent: (props: { size?: number; className?: string }) => ReactNode;
+ category: 'ai' | 'scraping' | 'social' | 'platform';
+ pricingModel: string;
+ usedInPhases: string[];
+ estimatedCostPerRun?: string;
+ rateLimit?: string;
+ notes?: string;
+}
+
+interface ApiStatusInfo extends ApiConfig {
+ status: ApiStatus;
+ keyPresent: Record;
+}
+
+/* ──────────────────── API Registry (source of truth) ──────────── */
+
+const API_REGISTRY: ApiConfig[] = [
+ {
+ id: 'supabase',
+ name: 'Supabase',
+ description: 'PostgreSQL DB + Auth + Edge Functions 호스팅',
+ envKeys: ['VITE_SUPABASE_URL', 'VITE_SUPABASE_ANON_KEY'],
+ layer: 'both',
+ docsUrl: 'https://supabase.com/docs',
+ IconComponent: BoltFilled,
+ category: 'platform',
+ pricingModel: 'Free tier → Pro $25/mo',
+ usedInPhases: ['All phases'],
+ rateLimit: 'Edge Functions: 500K invocations/mo (free)',
+ notes: '모든 데이터 저장 및 Edge Function 실행 플랫폼',
+ },
+ {
+ id: 'perplexity',
+ name: 'Perplexity AI',
+ description: 'LLM 기반 시장 분석 및 리포트 생성',
+ envKeys: ['PERPLEXITY_API_KEY'],
+ layer: 'edge-function',
+ docsUrl: 'https://docs.perplexity.ai',
+ IconComponent: PrismFilled,
+ category: 'ai',
+ pricingModel: 'Sonar: $1/1K requests',
+ usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Phase 3: generate'],
+ estimatedCostPerRun: '~$0.05-0.15',
+ rateLimit: '50 RPM (rate limit)',
+ notes: 'discover(2회) + collect(4회 병렬) + generate(1회) = 분석당 ~7회 호출',
+ },
+ {
+ id: 'firecrawl',
+ name: 'Firecrawl',
+ description: '웹 스크래핑, 스크린샷 캡처, 구조화 데이터 추출',
+ envKeys: ['FIRECRAWL_API_KEY'],
+ layer: 'edge-function',
+ docsUrl: 'https://docs.firecrawl.dev',
+ IconComponent: GlobeFilled,
+ category: 'scraping',
+ pricingModel: 'Free 500 credits → $19/mo',
+ usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
+ estimatedCostPerRun: '~5-10 credits',
+ rateLimit: '3 RPM (free), 20 RPM (paid)',
+ notes: 'scrape + map + search + fullPage screenshot',
+ },
+ {
+ id: 'apify',
+ name: 'Apify',
+ description: 'Instagram/Facebook/Google Places 데이터 수집',
+ envKeys: ['APIFY_API_TOKEN'],
+ layer: 'edge-function',
+ docsUrl: 'https://docs.apify.com',
+ IconComponent: ServerFilled,
+ category: 'scraping',
+ pricingModel: 'Free $5/mo → Starter $49/mo',
+ usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
+ estimatedCostPerRun: '~$0.10-0.30',
+ rateLimit: 'Actor-dependent (30-120s timeout)',
+ notes: 'IG profile/posts/reels + FB pages + Google Places actors',
+ },
+ {
+ id: 'youtube',
+ name: 'YouTube Data API v3',
+ description: 'YouTube 채널 검색, 통계, 영상 데이터',
+ envKeys: ['YOUTUBE_API_KEY'],
+ layer: 'edge-function',
+ docsUrl: 'https://developers.google.com/youtube/v3',
+ IconComponent: YoutubeFilled,
+ category: 'social',
+ pricingModel: 'Free (10,000 units/day)',
+ usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
+ estimatedCostPerRun: '~200-400 units',
+ rateLimit: '10,000 units/day',
+ notes: 'search(100u) + channels(5u) + videos(5u each)',
+ },
+ {
+ id: 'naver',
+ name: 'Naver Search API',
+ description: '네이버 블로그/웹/플레이스 검색',
+ envKeys: ['NAVER_CLIENT_ID', 'NAVER_CLIENT_SECRET'],
+ layer: 'edge-function',
+ docsUrl: 'https://developers.naver.com/docs/serviceapi/search/blog/blog.md',
+ IconComponent: MegaphoneFilled,
+ category: 'social',
+ pricingModel: 'Free (25,000/day)',
+ usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
+ estimatedCostPerRun: 'Free',
+ rateLimit: '25,000 calls/day',
+ notes: 'blog.json + webkr.json + local.json',
+ },
+ {
+ id: 'gemini',
+ name: 'Google Gemini',
+ description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
+ envKeys: ['GEMINI_API_KEY'],
+ layer: 'both',
+ docsUrl: 'https://ai.google.dev/docs',
+ IconComponent: DatabaseFilled,
+ category: 'ai',
+ pricingModel: 'Free tier → Pay-as-you-go',
+ usedInPhases: ['Phase 2: collect (Vision)', 'Studio (Image Gen)'],
+ estimatedCostPerRun: '~$0.01-0.05',
+ rateLimit: '15 RPM (free), 1000 RPM (paid)',
+ notes: 'gemini-2.5-flash-image 모델 사용',
+ },
+ {
+ id: 'creatomate',
+ name: 'Creatomate',
+ description: '마케팅 영상 자동 생성 (템플릿 기반)',
+ envKeys: ['VITE_CREATOMATE_API_KEY'],
+ layer: 'frontend',
+ docsUrl: 'https://creatomate.com/docs/api/introduction',
+ IconComponent: VideoFilled,
+ category: 'ai',
+ pricingModel: 'Free 5 renders → Pro $39/mo',
+ usedInPhases: ['Studio'],
+ estimatedCostPerRun: '1 render credit',
+ rateLimit: '2 concurrent renders (free)',
+ notes: 'POST /renders → poll until complete',
+ },
+];
+
+/* ────────────────── Environment Variable Checker ─────────────── */
+
+function checkEnvVars(): ApiStatusInfo[] {
+ return API_REGISTRY.map((api) => {
+ const keyPresent: Record = {};
+ for (const key of api.envKeys) {
+ if (key.startsWith('VITE_')) {
+ const val = (import.meta.env as Record)[key];
+ keyPresent[key] = !!val && val.length > 0;
+ } else {
+ keyPresent[key] = true;
+ }
+ }
+
+ const allPresent = Object.values(keyPresent).every(Boolean);
+ const nonePresent = Object.values(keyPresent).every((v) => !v);
+
+ return {
+ ...api,
+ status: nonePresent ? 'missing' : allPresent ? 'connected' : 'error',
+ keyPresent,
+ };
+ });
+}
+
+/* ──────────────────────── Sub-components ──────────────────────── */
+
+function StatusDot({ status }: { status: ApiStatus }) {
+ const styles = {
+ connected: 'bg-status-good-dot',
+ missing: 'bg-status-critical-dot',
+ error: 'bg-status-warning-dot',
+ };
+ return (
+
+ {status === 'connected' && (
+
+ )}
+
+
+ );
+}
+
+function StatusBadge({ status }: { status: ApiStatus }) {
+ const config = {
+ connected: { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Connected', Icon: CheckFilled },
+ missing: { bg: 'bg-status-critical-bg', text: 'text-status-critical-text', border: 'border-status-critical-border', label: 'Missing Key', Icon: CrossFilled },
+ error: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Partial', Icon: WarningFilled },
+ };
+ const c = config[status];
+ return (
+
+
+ {c.label}
+
+ );
+}
+
+function LayerBadge({ layer }: { layer: ApiLayer }) {
+ const config = {
+ frontend: { bg: 'bg-status-info-bg', text: 'text-status-info-text', border: 'border-status-info-border', label: 'Frontend', Icon: GlobeFilled },
+ 'edge-function': { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Edge Function', Icon: ServerFilled },
+ both: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Both', Icon: BoltFilled },
+ };
+ const c = config[layer];
+ return (
+
+
+ {c.label}
+
+ );
+}
+
+function CategoryBadge({ category }: { category: ApiConfig['category'] }) {
+ const config = {
+ ai: { bg: 'bg-status-good-bg', text: 'text-status-good-text', label: 'AI' },
+ scraping: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', label: 'Scraping' },
+ social: { bg: 'bg-status-info-bg', text: 'text-status-info-text', label: 'Social' },
+ platform: { bg: 'bg-primary-50', text: 'text-primary-800', label: 'Platform' },
+ };
+ const c = config[category];
+ return (
+
+ {c.label}
+
+ );
+}
+
+function EnvKeyRow({ envKey, present, isServerSide }: { envKey: string; present: boolean; isServerSide: boolean }) {
+ const [showKey, setShowKey] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const value = isServerSide
+ ? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
+ : showKey
+ ? (import.meta.env as Record)[envKey] || ''
+ : '••••••••••••••••';
+
+ const handleCopy = () => {
+ if (!isServerSide) {
+ navigator.clipboard.writeText((import.meta.env as Record)[envKey] || '');
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }
+ };
+
+ return (
+
+
+
{envKey}
+
{value}
+ {!isServerSide && (
+
+ setShowKey(!showKey)} className="p-1 hover:bg-slate-200 rounded">
+ {showKey
+ ?
+ : }
+
+
+ {copied
+ ?
+ : }
+
+
+ )}
+
+ );
+}
+
+function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+ {/* Header */}
+ setExpanded(!expanded)}
+ className="w-full px-6 py-5 flex items-center gap-4 text-left"
+ >
+
+
+
+
{api.name}
+
+
+
{api.description}
+
+
+
+
+
+
+
+
+ {/* Expandable detail */}
+ {expanded && (
+
+ {/* Env Keys */}
+
+
Environment Variables
+
+ {api.envKeys.map((key) => (
+
+ ))}
+
+
+
+ {/* Info Grid */}
+
+
+
Pricing
+
{api.pricingModel}
+
+ {api.estimatedCostPerRun && (
+
+
Cost / Run
+
{api.estimatedCostPerRun}
+
+ )}
+ {api.rateLimit && (
+
+
Rate Limit
+
{api.rateLimit}
+
+ )}
+
+
Used In
+
{api.usedInPhases.join(', ')}
+
+
+
+ {/* Notes + Docs link */}
+
+
+ )}
+
+ );
+}
+
+/* ──────────────────── Pipeline Flow Diagram ───────────────────── */
+
+function PipelineFlow({ apis }: { apis: ApiStatusInfo[] }) {
+ const phases = [
+ { name: 'Phase 1', label: 'Discover Channels', apis: ['firecrawl', 'youtube', 'naver', 'perplexity', 'apify'] },
+ { name: 'Phase 2', label: 'Collect Data', apis: ['apify', 'youtube', 'firecrawl', 'naver', 'perplexity', 'gemini'] },
+ { name: 'Phase 3', label: 'Generate Report', apis: ['perplexity'] },
+ { name: 'Studio', label: 'Content Creation', apis: ['gemini', 'creatomate'] },
+ ];
+
+ const apiMap = Object.fromEntries(apis.map((a) => [a.id, a]));
+
+ return (
+
+
+
+ Pipeline API Flow
+
+
+ {phases.map((phase, idx) => (
+
+
+ {phase.name}: {phase.label}
+
+
+ {phase.apis.map((apiId) => {
+ const api = apiMap[apiId];
+ if (!api) return null;
+ return (
+
+ );
+ })}
+
+ {idx < phases.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+/* ────────────────────── Cost Estimator ────────────────────────── */
+
+function CostEstimator({ apis }: { apis: ApiStatusInfo[] }) {
+ const costItems = apis
+ .filter((a) => a.estimatedCostPerRun)
+ .map((a) => ({
+ name: a.name,
+ IconComponent: a.IconComponent,
+ cost: a.estimatedCostPerRun!,
+ }));
+
+ return (
+
+
+
+ Estimated Cost per Analysis Run
+
+
+ {costItems.map((item) => (
+
+
+
+ {item.name}
+
+ {item.cost}
+
+ ))}
+
+ Total (estimated)
+ ~$0.26-0.80 / run
+
+
+
+ );
+}
+
+/* ──────────────────────── Main Page ───────────────────────────── */
+
+export default function ApiDashboardPage() {
+ const [apis, setApis] = useState([]);
+ const [lastChecked, setLastChecked] = useState(null);
+ const [filter, setFilter] = useState<'all' | 'connected' | 'missing'>('all');
+
+ const refreshStatus = () => {
+ setApis(checkEnvVars());
+ setLastChecked(new Date());
+ };
+
+ useEffect(() => {
+ refreshStatus();
+ }, []);
+
+ const connectedCount = apis.filter((a) => a.status === 'connected').length;
+ const missingCount = apis.filter((a) => a.status === 'missing').length;
+ const errorCount = apis.filter((a) => a.status === 'error').length;
+
+ const filteredApis = filter === 'all' ? apis : apis.filter((a) => a.status === filter);
+
+ return (
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+
+
API Dashboard
+
API 연결 상태, 환경변수, 사용량, 비용 현황
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+ Total APIs
+
+
{apis.length}
+
+
+
+
+ Connected
+
+
{connectedCount}
+
+
+
+
+ Missing
+
+
{missingCount}
+
+
+
+
+ Partial
+
+
{errorCount}
+
+
+
+ {/* Pipeline Flow */}
+
+
+
+
+ {/* Filter + Refresh */}
+
+
+ {(['all', 'connected', 'missing'] as const).map((f) => (
+ setFilter(f)}
+ className={`px-5 py-2.5 text-xs font-semibold rounded-full transition-all ${
+ filter === f
+ ? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
+ : 'bg-white text-slate-500 border border-slate-200 hover:bg-slate-50'
+ }`}
+ >
+ {f === 'all' ? 'All' : f === 'connected' ? 'Connected' : 'Missing'}
+
+ ))}
+
+
+ {lastChecked && (
+
+ Last checked: {lastChecked.toLocaleTimeString('ko-KR')}
+
+ )}
+
+
+ Refresh
+
+
+
+
+ {/* API Cards */}
+
+ {filteredApis.map((api, idx) => (
+
+ ))}
+
+
+ {/* Cost Estimator */}
+
+
+
+
+ {/* Footer note */}
+
+
+ VITE_ 접두사 키만 프론트엔드에서 확인 가능합니다. Edge Function 키는 Supabase Dashboard에서 확인하세요.
+
+
+
+ );
+}
diff --git a/src/pages/ReportPage.tsx b/src/pages/ReportPage.tsx
index ce9899b..af3bb95 100644
--- a/src/pages/ReportPage.tsx
+++ b/src/pages/ReportPage.tsx
@@ -6,6 +6,7 @@ import { ReportNav } from '../components/report/ReportNav';
import { ScreenshotProvider } from '../contexts/ScreenshotContext';
// Report section components
+import { SectionErrorBoundary } from '../components/report/ui/SectionErrorBoundary';
import ReportHeader from '../components/report/ReportHeader';
import ClinicSnapshot from '../components/report/ClinicSnapshot';
import ChannelOverview from '../components/report/ChannelOverview';
@@ -127,28 +128,28 @@ export default function ReportPage() {
brandColors={data.clinicSnapshot.brandColors}
/>
-
+
-
+
-
+
-
+
-
+
-
+ />
-
+
-
+
-
+
-
+
diff --git a/supabase/functions/_shared/googlePlaces.ts b/supabase/functions/_shared/googlePlaces.ts
new file mode 100644
index 0000000..85ec48b
--- /dev/null
+++ b/supabase/functions/_shared/googlePlaces.ts
@@ -0,0 +1,212 @@
+/**
+ * Google Places API (New) — Direct REST client.
+ *
+ * Replaces Apify `compass~crawler-google-places` actor with official API.
+ * Benefits: 1-2s latency (vs 30-120s), stable contract, ToS compliant.
+ *
+ * API docs: https://developers.google.com/maps/documentation/places/web-service/op-overview
+ *
+ * Pricing (per 1K calls):
+ * - Text Search (Basic): $32 → but we use field mask to reduce
+ * - Place Details (Basic): $0 (id-only fields)
+ * - Place Details (Contact): $3 (phone, website)
+ * - Place Details (Atmos): $5 (reviews, rating)
+ *
+ * Typical cost per clinic: ~$0.04 (1 Text Search + 1 Details)
+ */
+
+import { fetchWithRetry } from "./retry.ts";
+
+// ─── Types ───
+
+export interface GooglePlaceResult {
+ name: string; // e.g. "뷰성형외과의원"
+ rating: number | null; // e.g. 4.3
+ reviewCount: number; // e.g. 387
+ address: string; // formatted address
+ phone: string; // international format
+ clinicWebsite: string; // clinic's own website
+ mapsUrl: string; // Google Maps URL
+ category: string; // primary type
+ placeId: string; // for future lookups
+ openingHours: Record | null;
+ topReviews: {
+ stars: number;
+ text: string;
+ publishedAtDate: string;
+ }[];
+}
+
+// ─── Constants ───
+
+const PLACES_BASE = "https://places.googleapis.com/v1";
+
+// Fields we need from Text Search (covers Basic + Contact + Atmosphere tiers)
+const TEXT_SEARCH_FIELDS = [
+ "places.id",
+ "places.displayName",
+ "places.formattedAddress",
+ "places.rating",
+ "places.userRatingCount",
+ "places.nationalPhoneNumber",
+ "places.internationalPhoneNumber",
+ "places.websiteUri",
+ "places.googleMapsUri",
+ "places.primaryType",
+ "places.primaryTypeDisplayName",
+ "places.regularOpeningHours",
+ "places.reviews",
+].join(",");
+
+// ─── Main Function ───
+
+/**
+ * Search for a clinic on Google Places and return structured data.
+ * Tries multiple queries in order until a match is found.
+ */
+export async function searchGooglePlace(
+ clinicName: string,
+ address: string | undefined,
+ apiKey: string,
+): Promise {
+ const queries = [
+ `${clinicName} 성형외과`,
+ clinicName,
+ `${clinicName} ${address || "강남"}`,
+ ];
+
+ for (const query of queries) {
+ const result = await textSearch(query, apiKey);
+ if (result) return result;
+ }
+
+ return null;
+}
+
+/**
+ * Google Places Text Search (New) API call.
+ * Returns the first matching place with full details in a single call.
+ */
+async function textSearch(
+ query: string,
+ apiKey: string,
+): Promise {
+ const res = await fetchWithRetry(
+ `${PLACES_BASE}/places:searchText`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Goog-Api-Key": apiKey,
+ "X-Goog-FieldMask": TEXT_SEARCH_FIELDS,
+ },
+ body: JSON.stringify({
+ textQuery: query,
+ languageCode: "ko",
+ regionCode: "KR",
+ maxResultCount: 3,
+ }),
+ },
+ {
+ maxRetries: 1,
+ timeoutMs: 15000,
+ label: `google-places:textSearch`,
+ },
+ );
+
+ if (!res.ok) {
+ const errText = await res.text().catch(() => "");
+ throw new Error(`Google Places Text Search ${res.status}: ${errText}`);
+ }
+
+ const data = await res.json();
+ const places = data.places as Record[] | undefined;
+
+ if (!places || places.length === 0) return null;
+
+ // Use first result (most relevant)
+ return mapPlaceToResult(places[0]);
+}
+
+/**
+ * Map Google Places API response to our internal structure.
+ * Maintains the same field names as the old Apify-based structure
+ * for backward compatibility with transformReport.ts and UI components.
+ */
+function mapPlaceToResult(place: Record): GooglePlaceResult {
+ const displayName = place.displayName as Record | undefined;
+ const openingHours = place.regularOpeningHours as Record | null;
+ const reviews = (place.reviews as Record[]) || [];
+ const primaryTypeDisplay = place.primaryTypeDisplayName as Record | undefined;
+
+ return {
+ name: (displayName?.text as string) || "",
+ rating: (place.rating as number) || null,
+ reviewCount: (place.userRatingCount as number) || 0,
+ address: (place.formattedAddress as string) || "",
+ phone: (place.internationalPhoneNumber as string) || (place.nationalPhoneNumber as string) || "",
+ clinicWebsite: (place.websiteUri as string) || "",
+ mapsUrl: (place.googleMapsUri as string) || "",
+ category: (primaryTypeDisplay?.text as string) || (place.primaryType as string) || "",
+ placeId: (place.id as string) || "",
+ openingHours: openingHours,
+ topReviews: reviews.slice(0, 10).map((r) => {
+ const authorAttribution = r.authorAttribution as Record | undefined;
+ const publishTime = r.publishTime as string || "";
+ return {
+ stars: (r.rating as number) || 0,
+ text: ((r.text as Record)?.text as string || "").slice(0, 500),
+ publishedAtDate: publishTime,
+ };
+ }),
+ };
+}
+
+/**
+ * Get Place Details by Place ID — for future use when we have cached placeIds
+ * in the clinic_registry table. Cheaper than Text Search.
+ */
+export async function getPlaceDetails(
+ placeId: string,
+ apiKey: string,
+): Promise {
+ const fieldMask = [
+ "id",
+ "displayName",
+ "formattedAddress",
+ "rating",
+ "userRatingCount",
+ "nationalPhoneNumber",
+ "internationalPhoneNumber",
+ "websiteUri",
+ "googleMapsUri",
+ "primaryType",
+ "primaryTypeDisplayName",
+ "regularOpeningHours",
+ "reviews",
+ ].join(",");
+
+ const res = await fetchWithRetry(
+ `${PLACES_BASE}/places/${placeId}`,
+ {
+ method: "GET",
+ headers: {
+ "X-Goog-Api-Key": apiKey,
+ "X-Goog-FieldMask": fieldMask,
+ },
+ },
+ {
+ maxRetries: 1,
+ timeoutMs: 10000,
+ label: `google-places:details`,
+ },
+ );
+
+ if (!res.ok) {
+ const errText = await res.text().catch(() => "");
+ throw new Error(`Google Places Details ${res.status}: ${errText}`);
+ }
+
+ const place = await res.json();
+ return mapPlaceToResult(place as Record);
+}
diff --git a/supabase/functions/_shared/visionAnalysis.ts b/supabase/functions/_shared/visionAnalysis.ts
index cf23f33..62f3731 100644
--- a/supabase/functions/_shared/visionAnalysis.ts
+++ b/supabase/functions/_shared/visionAnalysis.ts
@@ -131,29 +131,46 @@ export function findRelevantPages(
): { doctorPage?: string; surgeryPage?: string; aboutPage?: string } {
const result: { doctorPage?: string; surgeryPage?: string; aboutPage?: string } = {};
+ // Phase 1: Classify each URL into one category (doctor > surgery > about priority)
+ const doctorUrls: string[] = [];
+ const surgeryUrls: string[] = [];
+ const aboutUrls: string[] = [];
+
for (const url of siteMap) {
const lower = url.toLowerCase();
- if (!result.doctorPage && (
- lower.includes('/doctor') || lower.includes('/team') || lower.includes('/staff') ||
+
+ const isDoctor = (
+ lower.includes('/doctor') || lower.includes('/doctors') ||
+ lower.includes('/team') || lower.includes('/staff') ||
lower.includes('/specialist') || lower.includes('/professor') ||
- lower.includes('/의료진') || lower.includes('/원장')
- )) {
- result.doctorPage = url;
- }
- if (!result.surgeryPage && (
+ lower.includes('/의료진') || lower.includes('/원장') ||
+ lower.includes('meet-our-doctor') // 하이픈 패턴 (그랜드성형외과 등)
+ );
+ const isSurgery = (
lower.includes('/surgery') || lower.includes('/service') || lower.includes('/procedure') ||
lower.includes('/treatment') || lower.includes('/시술') || lower.includes('/수술')
- )) {
- result.surgeryPage = url;
- }
- if (!result.aboutPage && (
+ );
+ const isAbout = (
lower.includes('/about') || lower.includes('/intro') || lower.includes('/소개') ||
- lower.includes('/greeting')
- )) {
- result.aboutPage = url;
+ lower.includes('/greeting') || lower.includes('/인사말') || lower.includes('/history') ||
+ lower.includes('/연혁')
+ );
+
+ // Priority: specific page type > generic /about
+ if (isDoctor) {
+ doctorUrls.push(url);
+ } else if (isSurgery) {
+ surgeryUrls.push(url);
+ } else if (isAbout) {
+ aboutUrls.push(url);
}
}
+ // Phase 2: Pick best URL per category
+ result.doctorPage = doctorUrls[0];
+ result.surgeryPage = surgeryUrls[0];
+ result.aboutPage = aboutUrls[0];
+
return result;
}
@@ -205,13 +222,72 @@ export async function captureAllScreenshots(
}
}
+ // Instagram EN (두 번째 계정이 있는 경우)
+ if (igList.length > 1) {
+ const igEn = igList[1];
+ const handleEn = (igEn.handle as string || '').replace(/^@/, '');
+ if (handleEn) {
+ captureTargets.push({ id: 'instagram-en', url: `https://www.instagram.com/${handleEn}/`, channel: 'Instagram', caption: `Instagram @${handleEn} (EN)` });
+ }
+ }
+
+ // Facebook
+ const fb = verifiedChannels.facebook as Record | null;
+ if (fb?.verified || fb?.verified === 'unverifiable') {
+ const fbUrl = (fb.url as string) || `https://www.facebook.com/${fb.handle}`;
+ captureTargets.push({ id: 'facebook-page', url: fbUrl, channel: 'Facebook', caption: 'Facebook 페이지' });
+ }
+
+ // TikTok
+ const tt = verifiedChannels.tiktok as Record | null;
+ if (tt?.handle) {
+ const ttHandle = (tt.handle as string).replace(/^@/, '');
+ captureTargets.push({ id: 'tiktok-landing', url: `https://www.tiktok.com/@${ttHandle}`, channel: 'TikTok', caption: `TikTok @${ttHandle}` });
+ }
+
// 강남언니
const gu = verifiedChannels.gangnamUnni as Record | null;
if (gu?.url) {
captureTargets.push({ id: 'gangnamunni-page', url: gu.url as string, channel: '강남언니', caption: '강남언니 병원 페이지' });
}
- // Capture all in parallel (max 6 concurrent)
+ // 네이버 블로그
+ const nb = verifiedChannels.naverBlog as Record | null;
+ if (nb?.handle) {
+ captureTargets.push({ id: 'naver-blog', url: `https://blog.naver.com/${nb.handle}`, channel: '네이버 블로그', caption: '공식 네이버 블로그' });
+ }
+
+ // 네이버 플레이스
+ const np = verifiedChannels.naverPlace as Record | null;
+ if (np?.url) {
+ captureTargets.push({ id: 'naver-place', url: np.url as string, channel: '네이버 플레이스', caption: '네이버 플레이스' });
+ }
+
+ // Google Maps
+ const gm = verifiedChannels.googleMaps as Record | null;
+ if (gm?.url) {
+ captureTargets.push({ id: 'google-maps', url: gm.url as string, channel: 'Google Maps', caption: 'Google Maps' });
+ }
+
+ // 전후사진/갤러리 페이지 (siteMap에서 탐색)
+ const galleryPage = siteMap.find(u => {
+ const l = u.toLowerCase();
+ return l.includes('/gallery') || l.includes('/before') || l.includes('/전후')
+ || l.includes('/photos') || l.includes('/case') || l.includes('/사례');
+ });
+ if (galleryPage) {
+ captureTargets.push({ id: 'website-gallery', url: galleryPage, channel: '웹사이트', caption: '전후사진/갤러리' });
+ }
+
+ // 영문 웹사이트
+ const enSite = verifiedChannels.websiteEn as string | null;
+ if (enSite) {
+ captureTargets.push({ id: 'website-en', url: enSite, channel: '웹사이트(EN)', caption: '영문 웹사이트' });
+ }
+
+ console.log(`[vision] Capture targets: ${captureTargets.length} pages (${captureTargets.map(t => t.id).join(', ')})`);
+
+ // Capture all in parallel
const capturePromises = captureTargets.map(async (target) => {
const result = await captureScreenshot(target.url, firecrawlKey);
if (result) {
@@ -248,7 +324,11 @@ export async function analyzeScreenshot(
"SINCE 2004" → 2004
"20년 전통" → 2026 - 20 = 2006
"개원 15주년" → 2026 - 15 = 2011
- 배너, 이벤트 팝업, 로고 옆 텍스트, 하단 footer 등 모든 곳을 확인해줘.
+ "2004년개원" → 2004
+ "2004년 개원" → 2004
+ "2004년개원 이래" → 2004
+ "설립 2004년" → 2004
+ 배너, 이벤트 팝업, 로고 옆 텍스트, 하단 footer, 원장 인사말, 병원 소개 섹션 등 페이지 전체를 꼼꼼히 확인해줘. 특히 스크롤 아래쪽도 확인할 것!
- operationYears: 운영 기간 (숫자만. "22주년"이면 22)
- certifications: 인증 마크 (JCI, 보건복지부, 의료관광 등)
- socialIcons: 보이는 소셜 미디어 아이콘 (Instagram, YouTube, Facebook, Blog, KakaoTalk 등)
@@ -268,6 +348,9 @@ export async function analyzeScreenshot(
"22년 동안" → 2026 - 22 = 2004
"SINCE 2004" → 2004
"2004년 개원" → 2004
+ "2004년개원" → 2004
+ "2004년개원 이래" → 2004
+ "설립 2004년" → 2004
"20년 전통" → 2026 - 20 = 2006
연혁, 소개글, 대표원장 인사말 등 모든 텍스트를 꼼꼼히 확인해줘.
- operationYears: 운영 기간 (숫자만)
@@ -288,6 +371,30 @@ export async function analyzeScreenshot(
'gangnamunni-page': `이 강남언니 병원 페이지를 분석해줘. JSON으로 추출:
- gangnamUnniStats: {rating: "평점 텍스트", reviews: "리뷰 수 텍스트", doctors: 의사 수(숫자)}
- doctors: [{name: "이름", specialty: "전문 분야"}] (보이는 의사 정보)`,
+
+ 'facebook-page': `이 Facebook 페이지 스크린샷을 분석해줘. JSON으로 추출:
+- facebookStats: {followers: "팔로워 수 텍스트", likes: "좋아요 수 텍스트", rating: "평점 (있으면)", category: "카테고리", recentPost: "최근 게시물 요약"}`,
+
+ 'tiktok-landing': `이 TikTok 프로필 스크린샷을 분석해줘. JSON으로 추출:
+- tiktokStats: {followers: "팔로워 수", likes: "총 좋아요 수", videos: "영상 수", bio: "바이오 텍스트", recentVideo: "최근 영상 설명"}`,
+
+ 'naver-blog': `이 네이버 블로그 스크린샷을 분석해줘. JSON으로 추출:
+- naverBlogStats: {neighbors: "이웃 수 텍스트", visitors: "방문자 수 텍스트 (있으면)", recentTitle: "최근 글 제목", blogName: "블로그 이름"}`,
+
+ 'naver-place': `이 네이버 플레이스 페이지를 분석해줘. JSON으로 추출:
+- naverPlaceStats: {rating: "별점", reviews: "리뷰 수", visitorReviews: "방문자 리뷰 수", address: "주소", hours: "영업시간", category: "카테고리"}`,
+
+ 'google-maps': `이 Google Maps 페이지를 분석해줘. JSON으로 추출:
+- googleMapsStats: {rating: "별점", reviews: "리뷰 수", address: "주소", hours: "영업시간 (있으면)", photos: "사진 수 (있으면)"}`,
+
+ 'website-gallery': `이 성형외과 전후사진/갤러리 페이지를 분석해줘. JSON으로 추출:
+- galleryStats: {estimatedPhotoCount: 추정 사진 수(숫자), categories: ["보이는 시술 카테고리 목록"]}`,
+
+ 'website-en': `Analyze this English version of a Korean plastic surgery clinic website. Extract JSON:
+- enSiteStats: {languages: ["supported languages visible"], targetMarkets: ["target countries/regions mentioned"], keyServices: ["listed services in English"]}`,
+
+ 'instagram-en': `이 Instagram 프로필(글로벌/영문 계정)을 분석해줘. JSON으로 추출:
+- instagramEnStats: {followers: "팔로워 수 텍스트", posts: "게시물 수 텍스트", bio: "바이오 텍스트", language: "주 사용 언어"}`,
};
const prompt = prompts[pageType] || `이 웹페이지 스크린샷을 분석해줘. 보이는 모든 텍스트와 정보를 JSON으로 추출해줘.`;
diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts
index 828f6ff..bd17566 100644
--- a/supabase/functions/collect-channel-data/index.ts
+++ b/supabase/functions/collect-channel-data/index.ts
@@ -4,6 +4,7 @@ import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
import { captureAllScreenshots, runVisionAnalysis, screenshotErrors, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
+import { searchGooglePlace } from "../_shared/googlePlaces.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -77,6 +78,7 @@ Deno.serve(async (req) => {
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 GOOGLE_PLACES_API_KEY = Deno.env.get("GOOGLE_PLACES_API_KEY") || "";
const channelData: Record = {};
const analysisData: Record = {};
@@ -116,6 +118,74 @@ Deno.serve(async (req) => {
}));
}
+ // ─── 1b. Instagram Posts (최근 20개 포스트 상세) ───
+ const igPrimaryHandle = igCandidates[0]?.handle as string | undefined;
+ if (APIFY_TOKEN && igPrimaryHandle) {
+ channelTasks.push(wrapChannelTask("instagramPosts", async () => {
+ const handle = igPrimaryHandle.replace(/^@/, '');
+ const items = await runApifyActor(
+ "apify~instagram-post-scraper",
+ { directUrls: [`https://www.instagram.com/${handle}/`], resultsLimit: 20 },
+ APIFY_TOKEN,
+ );
+ const posts = (items as Record[]).map(p => ({
+ id: p.id,
+ type: p.type,
+ shortCode: p.shortCode,
+ url: p.url,
+ caption: ((p.caption as string) || '').slice(0, 500),
+ hashtags: p.hashtags || [],
+ mentions: p.mentions || [],
+ likesCount: (p.likesCount as number) || 0,
+ commentsCount: (p.commentsCount as number) || 0,
+ timestamp: p.timestamp,
+ displayUrl: p.displayUrl,
+ }));
+ const totalLikes = posts.reduce((sum, p) => sum + p.likesCount, 0);
+ const totalComments = posts.reduce((sum, p) => sum + p.commentsCount, 0);
+ channelData.instagramPosts = {
+ posts,
+ totalPosts: posts.length,
+ avgLikes: posts.length > 0 ? Math.round(totalLikes / posts.length) : 0,
+ avgComments: posts.length > 0 ? Math.round(totalComments / posts.length) : 0,
+ };
+ }));
+ }
+
+ // ─── 1c. Instagram Reels (최근 15개 릴스 상세) ───
+ if (APIFY_TOKEN && igPrimaryHandle) {
+ channelTasks.push(wrapChannelTask("instagramReels", async () => {
+ const handle = igPrimaryHandle.replace(/^@/, '');
+ const items = await runApifyActor(
+ "apify~instagram-reel-scraper",
+ { directUrls: [`https://www.instagram.com/${handle}/reels/`], resultsLimit: 15 },
+ APIFY_TOKEN,
+ );
+ const reels = (items as Record[]).map(r => ({
+ id: r.id,
+ shortCode: r.shortCode,
+ url: r.url,
+ caption: ((r.caption as string) || '').slice(0, 500),
+ hashtags: r.hashtags || [],
+ likesCount: (r.likesCount as number) || 0,
+ commentsCount: (r.commentsCount as number) || 0,
+ videoViewCount: (r.videoViewCount as number) || 0,
+ videoPlayCount: (r.videoPlayCount as number) || 0,
+ videoDuration: (r.videoDuration as number) || 0,
+ timestamp: r.timestamp,
+ musicInfo: r.musicInfo || null,
+ }));
+ const totalViews = reels.reduce((sum, r) => sum + r.videoViewCount, 0);
+ const totalPlays = reels.reduce((sum, r) => sum + r.videoPlayCount, 0);
+ channelData.instagramReels = {
+ reels,
+ totalReels: reels.length,
+ avgViews: reels.length > 0 ? Math.round(totalViews / reels.length) : 0,
+ avgPlays: reels.length > 0 ? Math.round(totalPlays / reels.length) : 0,
+ };
+ }));
+ }
+
// ─── 2. YouTube ───
const ytVerified = verified.youtube as Record | null;
if (YOUTUBE_API_KEY && (ytVerified?.verified === true || ytVerified?.verified === "unverifiable")) {
@@ -307,28 +377,19 @@ Deno.serve(async (req) => {
}));
}
- // ─── 6. Google Maps ───
- if (APIFY_TOKEN && clinicName) {
+ // ─── 6. Google Maps (Google Places API New) ───
+ if (GOOGLE_PLACES_API_KEY && clinicName) {
channelTasks.push(wrapChannelTask("googleMaps", 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];
+ const place = await searchGooglePlace(clinicName, address || undefined, GOOGLE_PLACES_API_KEY);
if (place) {
channelData.googleMaps = {
- name: place.title, rating: place.totalScore, reviewCount: place.reviewsCount,
+ name: place.name, rating: place.rating, reviewCount: place.reviewCount,
address: place.address, phone: place.phone,
- clinicWebsite: place.website, // clinic's own website (not Maps URL)
- mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''),
- 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,
- })),
+ clinicWebsite: place.clinicWebsite,
+ mapsUrl: place.mapsUrl,
+ placeId: place.placeId,
+ category: place.category, openingHours: place.openingHours,
+ topReviews: place.topReviews,
};
} else {
throw new Error("Google Maps: no matching place found");
diff --git a/supabase/functions/enrich-channels/index.ts b/supabase/functions/enrich-channels/index.ts
index eb36dc0..6ac2d80 100644
--- a/supabase/functions/enrich-channels/index.ts
+++ b/supabase/functions/enrich-channels/index.ts
@@ -1,6 +1,7 @@
import "@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { normalizeInstagramHandle, normalizeYouTubeChannel } from "../_shared/normalizeHandles.ts";
+import { searchGooglePlace } from "../_shared/googlePlaces.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -59,6 +60,7 @@ Deno.serve(async (req) => {
const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN");
if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured");
+ const GOOGLE_PLACES_API_KEY = Deno.env.get("GOOGLE_PLACES_API_KEY") || "";
const enrichment: Record = {};
@@ -135,50 +137,28 @@ Deno.serve(async (req) => {
);
}
- // 2. Google Maps / Place Reviews
- if (clinicName || address) {
+ // 2. Google Maps / Place Reviews (Google Places API New)
+ if ((clinicName || address) && GOOGLE_PLACES_API_KEY) {
tasks.push(
(async () => {
- // Try multiple search queries for better hit rate
- const queries = [
- `${clinicName} 성형외과`,
- clinicName,
- `${clinicName} ${address || "강남"}`,
- ];
-
- let items: unknown[] = [];
- for (const query of queries) {
- items = await runApifyActor(
- "compass~crawler-google-places",
- {
- searchStringsArray: [query],
- maxCrawledPlacesPerSearch: 3,
- language: "ko",
- maxReviews: 10,
- },
- APIFY_TOKEN
- );
- if ((items as Record[]).length > 0) break;
- }
- const place = (items as Record[])[0];
+ const place = await searchGooglePlace(
+ clinicName || "",
+ address || undefined,
+ GOOGLE_PLACES_API_KEY,
+ );
if (place) {
enrichment.googleMaps = {
- name: place.title,
- rating: place.totalScore,
- reviewCount: place.reviewsCount,
+ name: place.name,
+ rating: place.rating,
+ reviewCount: place.reviewCount,
address: place.address,
phone: place.phone,
- clinicWebsite: place.website,
- mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''),
- category: place.categoryName,
+ clinicWebsite: place.clinicWebsite,
+ mapsUrl: place.mapsUrl,
+ placeId: place.placeId,
+ category: place.category,
openingHours: place.openingHours,
- topReviews: ((place.reviews as Record[]) || [])
- .slice(0, 10)
- .map((r) => ({
- stars: r.stars,
- text: (r.text as string || "").slice(0, 200),
- publishedAtDate: r.publishedAtDate,
- })),
+ topReviews: place.topReviews,
};
}
})()
diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts
index 2c78d8c..c693e8d 100644
--- a/supabase/functions/generate-report/index.ts
+++ b/supabase/functions/generate-report/index.ts
@@ -134,14 +134,19 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
// ─── Post-processing: Inject Vision Analysis data directly ───
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
const vision = channelData.visionAnalysis as Record | undefined;
+ const isEstablishedMissing = (val: unknown): boolean => {
+ if (!val) return true;
+ const s = String(val).trim().toLowerCase();
+ return s === '' || s === '데이터 없음' || s === '데이터없음' || s === 'n/a' || s === '확인불가' || s === '미확인' || s === '정보없음' || s === '정보 없음';
+ };
if (vision) {
// Force-inject foundingYear if Vision found it but Perplexity didn't
- if (vision.foundingYear && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
+ if (vision.foundingYear && isEstablishedMissing(report.clinicInfo?.established)) {
report.clinicInfo = report.clinicInfo || {};
report.clinicInfo.established = String(vision.foundingYear);
console.log(`[report] Injected foundingYear from Vision: ${vision.foundingYear}`);
}
- if (vision.operationYears && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
+ if (vision.operationYears && isEstablishedMissing(report.clinicInfo?.established)) {
const year = new Date().getFullYear() - Number(vision.operationYears);
report.clinicInfo = report.clinicInfo || {};
report.clinicInfo.established = String(year);
@@ -202,6 +207,78 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
caption: ss.caption,
sourceUrl: ss.sourceUrl,
}));
+
+ // 채널별 스크린샷 그룹핑 (프론트엔드에서 각 채널 섹션에 증거 이미지 표시)
+ report.channelScreenshots = screenshots.reduce((acc: Record, ss: Record) => {
+ const channel = ss.channel as string || 'etc';
+ if (!acc[channel]) acc[channel] = [];
+ acc[channel].push({ url: ss.url, caption: ss.caption, id: ss.id, sourceUrl: ss.sourceUrl });
+ return acc;
+ }, {} as Record);
+ }
+
+ // Instagram Posts/Reels 분석 (engagement rate, 콘텐츠 성과)
+ const igProfile = channelData.instagram as Record | undefined;
+ const igPosts = channelData.instagramPosts as Record | undefined;
+ const igReels = channelData.instagramReels as Record | undefined;
+ if (igProfile && (igPosts || igReels)) {
+ const followers = (igProfile.followers as number) || 0;
+ const avgLikes = (igPosts?.avgLikes as number) || 0;
+ const avgComments = (igPosts?.avgComments as number) || 0;
+
+ // 해시태그 빈도 분석
+ const hashtagCounts: Record = {};
+ const allPosts = [...((igPosts?.posts as Record[]) || []), ...((igReels?.reels as Record[]) || [])];
+ for (const post of allPosts) {
+ for (const tag of (post.hashtags as string[]) || []) {
+ const lower = tag.toLowerCase();
+ hashtagCounts[lower] = (hashtagCounts[lower] || 0) + 1;
+ }
+ }
+ const topHashtags = Object.entries(hashtagCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10)
+ .map(([tag, count]) => ({ tag, count }));
+
+ // 게시 빈도 (최근 포스트 간 평균 간격)
+ const postTimestamps = ((igPosts?.posts as Record[]) || [])
+ .map(p => new Date(p.timestamp as string).getTime())
+ .filter(t => !isNaN(t))
+ .sort((a, b) => b - a);
+ let postFrequency = 'N/A';
+ if (postTimestamps.length >= 2) {
+ const spanDays = (postTimestamps[0] - postTimestamps[postTimestamps.length - 1]) / (1000 * 60 * 60 * 24);
+ const postsPerWeek = spanDays > 0 ? (postTimestamps.length / spanDays * 7).toFixed(1) : 'N/A';
+ postFrequency = `${postsPerWeek}회/주`;
+ }
+
+ // 최고 성과 포스트
+ const bestPost = ((igPosts?.posts as Record[]) || [])
+ .sort((a, b) => ((b.likesCount as number) || 0) - ((a.likesCount as number) || 0))[0] || null;
+
+ report.instagramAnalysis = {
+ engagementRate: followers > 0 ? ((avgLikes + avgComments) / followers * 100).toFixed(2) + '%' : 'N/A',
+ avgLikes,
+ avgComments,
+ topHashtags,
+ postFrequency,
+ bestPerformingPost: bestPost ? {
+ url: bestPost.url,
+ likes: bestPost.likesCount,
+ comments: bestPost.commentsCount,
+ caption: ((bestPost.caption as string) || '').slice(0, 200),
+ } : null,
+ reelsPerformance: igReels ? {
+ totalReels: igReels.totalReels,
+ avgViews: igReels.avgViews,
+ avgPlays: igReels.avgPlays,
+ viewToFollowerRatio: followers > 0
+ ? (((igReels.avgViews as number) || 0) / followers * 100).toFixed(1) + '%'
+ : 'N/A',
+ } : null,
+ totalPostsAnalyzed: (igPosts?.totalPosts as number) || 0,
+ totalReelsAnalyzed: (igReels?.totalReels as number) || 0,
+ };
}
// Embed verified handles