From ec991057e6edf9198d7dda32bdccc71bf45e926a Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Mon, 6 Apr 2026 14:59:31 +0900 Subject: [PATCH] feat: add API Dashboard + filled icons + pipeline improvements - Add /api-dashboard page with API connection status, env var checker, pipeline flow diagram, and cost estimator - Add 15 new filled SVG icons (Shield, Database, Server, Bolt, Eye, Copy, Check, Cross, Warning, Refresh, Flow, Coin, LinkExternal etc.) - Follow INFINITH design system: no emoji, no line icons, semantic status colors, diagonal shadows, brand gradients, font-serif headings - Improve Vision Analysis with base64 encoding fix - Add SectionErrorBoundary for graceful section-level error handling - Add Google Places API utility (prepared for future migration) - Fix Edge Function auth headers and report generation pipeline Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/launch.json | 3 +- .claude/settings.local.json | 9 +- .gitignore | 1 + .vercelignore | 1 + .vscode/extensions.json | 3 + .vscode/settings.json | 24 + src/components/Hero.tsx | 16 +- src/components/icons/FilledIcons.tsx | 142 ++++ src/components/report/KPIDashboard.tsx | 15 +- src/components/report/ProblemDiagnosis.tsx | 7 +- .../report/ui/SectionErrorBoundary.tsx | 33 + src/lib/transformReport.ts | 23 +- src/main.tsx | 2 + src/pages/ApiDashboardPage.tsx | 621 ++++++++++++++++++ src/pages/ReportPage.tsx | 23 +- supabase/functions/_shared/googlePlaces.ts | 212 ++++++ supabase/functions/_shared/visionAnalysis.ts | 139 +++- .../functions/collect-channel-data/index.ts | 97 ++- supabase/functions/enrich-channels/index.ts | 54 +- supabase/functions/generate-report/index.ts | 81 ++- 20 files changed, 1397 insertions(+), 109 deletions(-) create mode 100644 .vercelignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 src/components/report/ui/SectionErrorBoundary.tsx create mode 100644 src/pages/ApiDashboardPage.tsx create mode 100644 supabase/functions/_shared/googlePlaces.ts 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 마케팅 엔진.{' '}콘텐츠 기획,{' '}생성,{' '}영상 제작,{' '}채널 배포,{' '}데이터 분석까지{' '}하나로.
- @@ -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 && ( +
+ + +
+ )} +
+ ); +} + +function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) { + const [expanded, setExpanded] = useState(false); + + return ( + + {/* Header */} + + + {/* 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 */} +
+ {api.notes && ( +

{api.notes}

+ )} + + API Docs + +
+
+ )} +
+ ); +} + +/* ──────────────────── 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 ( +
+ + + {api.name} +
+ ); + })} +
+ {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) => ( + + ))} +
+
+ {lastChecked && ( + + Last checked: {lastChecked.toLocaleTimeString('ko-KR')} + + )} + +
+
+ + {/* 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