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) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-06 14:59:31 +09:00
parent 2ca9ec0306
commit ec991057e6
20 changed files with 1397 additions and 109 deletions

View File

@ -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
}
]

View File

@ -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)"
]
}
}

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ coverage/
.env*
!.env.example
.vercel
doc/

1
.vercelignore Normal file
View File

@ -0,0 +1 @@
reference/

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["denoland.vscode-deno"]
}

24
.vscode/settings.json vendored Normal file
View File

@ -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"
}
}

View File

@ -17,7 +17,7 @@ export default function Hero() {
{/* Background Gradient */}
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-70"></div>
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto relative isolate">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -45,7 +45,7 @@ export default function Hero() {
className="text-lg md:text-xl text-slate-600 mb-10 max-w-3xl mx-auto leading-relaxed"
>
Marketing that learns, improves, and accelerates automatically. <br className="hidden md:block"/>
AI . , , , , .
<span className="whitespace-nowrap"> </span>{' '}<span className="whitespace-nowrap">AI .</span>{' '}<span className="whitespace-nowrap"> ,</span>{' '}<span className="whitespace-nowrap">,</span>{' '}<span className="whitespace-nowrap"> ,</span>{' '}<span className="whitespace-nowrap"> ,</span>{' '}<span className="whitespace-nowrap"> </span>{' '}<span className="whitespace-nowrap">.</span>
</motion.p>
<motion.div
@ -64,7 +64,7 @@ export default function Hero() {
className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300"
/>
</div>
<button onClick={handleAnalyze} className={`w-full px-8 py-5 text-base font-bold text-white rounded-2xl transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-3 group hover:scale-[1.02] active:scale-[0.98] ${url.trim() ? 'bg-gradient-to-r from-accent to-[#021341]' : 'bg-slate-300 cursor-not-allowed'}`} disabled={!url.trim()}>
<button onClick={handleAnalyze} className={`relative z-10 w-full px-8 py-5 text-base font-bold rounded-2xl transition-all flex items-center justify-center gap-3 group ${url.trim() ? 'bg-gradient-to-r from-accent to-[#8B5CF6] text-white shadow-xl hover:shadow-2xl hover:scale-[1.02] active:scale-[0.98]' : 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-sm'}`} disabled={!url.trim()}>
Analyze Marketing Performance
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
@ -74,10 +74,12 @@ export default function Hero() {
</motion.div>
</div>
{/* Decorative elements */}
{/* Decorative elements — isolate prevents mix-blend-multiply from bleeding into content */}
<div className="absolute inset-0 -z-[5] isolate pointer-events-none">
<div className="absolute top-1/4 left-10 w-64 h-64 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div className="absolute top-1/3 right-10 w-64 h-64 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 w-64 h-64 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
</section>
);
}

View File

@ -134,6 +134,148 @@ export function MusicFilled({ size = 20, className = '' }: IconProps) {
);
}
/* ─── Dashboard / Utility Icons ─── */
export function ShieldFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.25" />
<path d="M12 2L3 7V12C3 17.5 6.8 22.7 12 24C17.2 22.7 21 17.5 21 12V7L12 2Z" fill="currentColor" opacity="0.3" />
<path d="M10 14L8.5 12.5L7.5 13.5L10 16L16.5 9.5L15.5 8.5L10 14Z" fill="currentColor" />
</svg>
);
}
export function DatabaseFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<ellipse cx="12" cy="6" rx="8" ry="3" fill="currentColor" opacity="0.35" />
<path d="M4 6V12C4 13.66 7.58 15 12 15C16.42 15 20 13.66 20 12V6" fill="currentColor" opacity="0.25" />
<path d="M4 12V18C4 19.66 7.58 21 12 21C16.42 21 20 19.66 20 18V12" fill="currentColor" opacity="0.35" />
<ellipse cx="12" cy="18" rx="8" ry="3" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function ServerFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="2" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
<rect x="3" y="14" width="18" height="8" rx="2" fill="currentColor" opacity="0.3" />
<circle cx="7" cy="6" r="1.5" fill="currentColor" />
<circle cx="7" cy="18" r="1.5" fill="currentColor" />
<rect x="11" y="5" width="6" height="2" rx="1" fill="currentColor" opacity="0.5" />
<rect x="11" y="17" width="6" height="2" rx="1" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function BoltFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" opacity="0.25" />
<path d="M13 2L4 14H11L10 22L20 10H13L13 2Z" fill="currentColor" />
</svg>
);
}
export function EyeFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.2" />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>
);
}
export function EyeOffFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z" fill="currentColor" opacity="0.15" />
<circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.3" />
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function CopyFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="8" y="8" width="12" height="14" rx="2" fill="currentColor" opacity="0.25" />
<rect x="4" y="2" width="12" height="14" rx="2" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function CheckFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
<path d="M8 12L11 15L16 9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function CrossFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
<path d="M8 8L16 16M16 8L8 16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function WarningFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M12 2L1 21H23L12 2Z" fill="currentColor" opacity="0.25" />
<rect x="11" y="9" width="2" height="6" rx="1" fill="currentColor" />
<circle cx="12" cy="18" r="1.2" fill="currentColor" />
</svg>
);
}
export function RefreshFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.15" />
<path d="M17.65 6.35A7.95 7.95 0 0012 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z" fill="currentColor" />
</svg>
);
}
export function FlowFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="3" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.35" />
<rect x="9" y="9" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.5" />
<rect x="16" y="15" width="6" height="6" rx="1.5" fill="currentColor" />
<path d="M8 6H9.5C10.33 6 11 6.67 11 7.5V9" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
<path d="M15 12H16.5C17.33 12 18 12.67 18 13.5V15" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
</svg>
);
}
export function CoinFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
<circle cx="12" cy="12" r="7" fill="currentColor" opacity="0.35" />
<text x="12" y="16" textAnchor="middle" fontSize="10" fontWeight="bold" fill="currentColor">$</text>
</svg>
);
}
export function LinkExternalFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="5" width="14" height="14" rx="2" fill="currentColor" opacity="0.2" />
<path d="M14 3H21V10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 3L10 14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
/**
* InfinityLoopFilled Infinite Marketing loop icon.
* HubSpot-style infinity loop with gradient shading.

View File

@ -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) {

View File

@ -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 (

View File

@ -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<Props, State> {
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 ?? (
<div className="px-6 py-4 bg-red-50 border border-red-200 rounded-xl my-4 mx-4">
<p className="text-xs font-mono text-red-600"> ( )</p>
</div>
);
}
return this.props.children;
}
}

View File

@ -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),
});
}
}

View File

@ -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(
<Route path="performance" element={<PerformancePage />} />
<Route path="data-validation" element={<DataValidationPage />} />
<Route path="clinic/:id" element={<ClinicProfilePage />} />
<Route path="api-dashboard" element={<ApiDashboardPage />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -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<string, boolean>;
}
/* ──────────────────── 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<string, boolean> = {};
for (const key of api.envKeys) {
if (key.startsWith('VITE_')) {
const val = (import.meta.env as Record<string, string | undefined>)[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 (
<span className="relative flex h-3 w-3">
{status === 'connected' && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-40 ${styles[status]}`} />
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${styles[status]}`} />
</span>
);
}
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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-semibold rounded-full border ${c.bg} ${c.text} ${c.border}`}>
<c.Icon size={12} className={c.text} />
{c.label}
</span>
);
}
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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-md border ${c.bg} ${c.text} ${c.border}`}>
<c.Icon size={12} className={c.text} />
{c.label}
</span>
);
}
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 (
<span className={`px-2 py-0.5 text-[11px] font-medium rounded-md ${c.bg} ${c.text}`}>
{c.label}
</span>
);
}
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<string, string | undefined>)[envKey] || ''
: '••••••••••••••••';
const handleCopy = () => {
if (!isServerSide) {
navigator.clipboard.writeText((import.meta.env as Record<string, string | undefined>)[envKey] || '');
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<div className="flex items-center gap-2 py-1.5 px-3 rounded-lg bg-primary-50 text-xs font-mono group">
<StatusDot status={present ? 'connected' : 'missing'} />
<span className="text-slate-500 min-w-[200px]">{envKey}</span>
<span className="text-slate-400 flex-1 truncate">{value}</span>
{!isServerSide && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setShowKey(!showKey)} className="p-1 hover:bg-slate-200 rounded">
{showKey
? <EyeOffFilled size={14} className="text-slate-400" />
: <EyeFilled size={14} className="text-slate-400" />}
</button>
<button onClick={handleCopy} className="p-1 hover:bg-slate-200 rounded">
{copied
? <CheckFilled size={14} className="text-status-good-dot" />
: <CopyFilled size={14} className="text-slate-400" />}
</button>
</div>
)}
</div>
);
}
function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-6 py-5 flex items-center gap-4 text-left"
>
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<api.IconComponent size={20} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-serif font-bold text-primary-900 text-base">{api.name}</h3>
<StatusDot status={api.status} />
</div>
<p className="text-xs text-slate-500 truncate">{api.description}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<CategoryBadge category={api.category} />
<LayerBadge layer={api.layer} />
<StatusBadge status={api.status} />
</div>
</button>
{/* Expandable detail */}
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="border-t border-slate-100 px-6 py-4 space-y-4"
>
{/* Env Keys */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Environment Variables</h4>
<div className="space-y-1.5">
{api.envKeys.map((key) => (
<EnvKeyRow
key={key}
envKey={key}
present={api.keyPresent[key]}
isServerSide={!key.startsWith('VITE_')}
/>
))}
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Pricing</div>
<div className="text-xs font-semibold text-slate-700">{api.pricingModel}</div>
</div>
{api.estimatedCostPerRun && (
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Cost / Run</div>
<div className="text-xs font-semibold text-slate-700">{api.estimatedCostPerRun}</div>
</div>
)}
{api.rateLimit && (
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Rate Limit</div>
<div className="text-xs font-semibold text-slate-700">{api.rateLimit}</div>
</div>
)}
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Used In</div>
<div className="text-xs font-semibold text-slate-700">{api.usedInPhases.join(', ')}</div>
</div>
</div>
{/* Notes + Docs link */}
<div className="flex items-center justify-between">
{api.notes && (
<p className="text-xs text-slate-500 italic">{api.notes}</p>
)}
<a
href={api.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline flex-shrink-0"
>
API Docs <LinkExternalFilled size={12} className="text-accent" />
</a>
</div>
</motion.div>
)}
</motion.div>
);
}
/* ──────────────────── 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 (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="font-serif font-bold text-primary-900 text-base mb-5 flex items-center gap-2">
<FlowFilled size={18} className="text-accent" />
Pipeline API Flow
</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{phases.map((phase, idx) => (
<div key={phase.name} className="relative">
<div className="bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-xs font-bold px-3 py-1.5 rounded-t-xl">
{phase.name}: {phase.label}
</div>
<div className="border border-t-0 border-slate-200 rounded-b-xl p-3 space-y-1.5 min-h-[100px]">
{phase.apis.map((apiId) => {
const api = apiMap[apiId];
if (!api) return null;
return (
<div key={apiId} className="flex items-center gap-2 text-xs">
<StatusDot status={api.status} />
<api.IconComponent size={14} className="text-slate-400" />
<span className="text-slate-600">{api.name}</span>
</div>
);
})}
</div>
{idx < phases.length - 1 && (
<div className="hidden md:flex absolute -right-3 top-1/2 transform -translate-y-1/2 z-10">
<ArrowRight className="w-4 h-4 text-slate-300" />
</div>
)}
</div>
))}
</div>
</div>
);
}
/* ────────────────────── 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 (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="font-serif font-bold text-primary-900 text-base mb-4 flex items-center gap-2">
<CoinFilled size={18} className="text-accent" />
Estimated Cost per Analysis Run
</h3>
<div className="space-y-2">
{costItems.map((item) => (
<div key={item.name} className="flex items-center justify-between py-2 px-3 bg-primary-50 rounded-xl">
<span className="text-sm text-slate-700 flex items-center gap-2">
<item.IconComponent size={16} className="text-slate-400" />
{item.name}
</span>
<span className="text-sm font-bold text-primary-900">{item.cost}</span>
</div>
))}
<div className="flex items-center justify-between py-3 px-3 bg-accent/5 rounded-xl border border-accent/20 mt-3">
<span className="text-sm font-bold text-primary-900">Total (estimated)</span>
<span className="text-sm font-black text-accent">~$0.26-0.80 / run</span>
</div>
</div>
</div>
);
}
/* ──────────────────────── Main Page ───────────────────────────── */
export default function ApiDashboardPage() {
const [apis, setApis] = useState<ApiStatusInfo[]>([]);
const [lastChecked, setLastChecked] = useState<Date | null>(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 (
<div className="min-h-screen bg-slate-50 pt-28 pb-16 px-6">
<div className="max-w-7xl mx-auto space-y-8">
{/* Page Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex items-center gap-3 mb-2">
<div className="p-2.5 bg-accent/10 rounded-xl">
<ShieldFilled size={24} className="text-accent" />
</div>
<div>
<h1 className="font-serif text-3xl font-bold text-primary-900">API Dashboard</h1>
<p className="text-sm text-slate-500">API , , , </p>
</div>
</div>
</motion.div>
{/* Summary Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<DatabaseFilled size={16} className="text-slate-400" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total APIs</span>
</div>
<div className="text-3xl font-black text-primary-900">{apis.length}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<CheckFilled size={16} className="text-status-good-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Connected</span>
</div>
<div className="text-3xl font-black text-status-good-text">{connectedCount}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<CrossFilled size={16} className="text-status-critical-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Missing</span>
</div>
<div className="text-3xl font-black text-status-critical-text">{missingCount}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<WarningFilled size={16} className="text-status-warning-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Partial</span>
</div>
<div className="text-3xl font-black text-status-warning-text">{errorCount}</div>
</div>
</motion.div>
{/* Pipeline Flow */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<PipelineFlow apis={apis} />
</motion.div>
{/* Filter + Refresh */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{(['all', 'connected', 'missing'] as const).map((f) => (
<button
key={f}
onClick={() => 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'}
</button>
))}
</div>
<div className="flex items-center gap-3">
{lastChecked && (
<span className="text-xs text-slate-400">
Last checked: {lastChecked.toLocaleTimeString('ko-KR')}
</span>
)}
<button
onClick={refreshStatus}
className="inline-flex items-center gap-1.5 px-5 py-2.5 text-xs font-semibold bg-white border border-slate-200 rounded-full hover:bg-slate-50 transition-all text-slate-600"
>
<RefreshFilled size={14} className="text-slate-500" />
Refresh
</button>
</div>
</div>
{/* API Cards */}
<div className="space-y-3">
{filteredApis.map((api, idx) => (
<ApiCard key={api.id} api={api} index={idx} />
))}
</div>
{/* Cost Estimator */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<CostEstimator apis={apis} />
</motion.div>
{/* Footer note */}
<div className="text-center text-xs text-slate-400 pt-4">
<PrismFilled size={14} className="text-slate-300 inline mr-1" />
VITE_ . Edge Function Supabase Dashboard .
</div>
</div>
</div>
);
}

View File

@ -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}
/>
<ClinicSnapshot data={data.clinicSnapshot} />
<SectionErrorBoundary><ClinicSnapshot data={data.clinicSnapshot} /></SectionErrorBoundary>
<ChannelOverview channels={data.channelScores} />
<SectionErrorBoundary><ChannelOverview channels={data.channelScores} /></SectionErrorBoundary>
<YouTubeAudit data={data.youtubeAudit} />
<SectionErrorBoundary><YouTubeAudit data={data.youtubeAudit} /></SectionErrorBoundary>
<InstagramAudit data={data.instagramAudit} />
<SectionErrorBoundary><InstagramAudit data={data.instagramAudit} /></SectionErrorBoundary>
<FacebookAudit data={data.facebookAudit} />
<SectionErrorBoundary><FacebookAudit data={data.facebookAudit} /></SectionErrorBoundary>
<OtherChannels
<SectionErrorBoundary><OtherChannels
channels={data.otherChannels}
website={data.websiteAudit}
/>
/></SectionErrorBoundary>
<ProblemDiagnosis diagnosis={data.problemDiagnosis} />
<SectionErrorBoundary><ProblemDiagnosis diagnosis={data.problemDiagnosis} /></SectionErrorBoundary>
<TransformationProposal data={data.transformation} />
<SectionErrorBoundary><TransformationProposal data={data.transformation} /></SectionErrorBoundary>
<RoadmapTimeline months={data.roadmap} />
<SectionErrorBoundary><RoadmapTimeline months={data.roadmap} /></SectionErrorBoundary>
<KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} />
<SectionErrorBoundary><KPIDashboard metrics={data.kpiDashboard} clinicName={data.clinicSnapshot.name} /></SectionErrorBoundary>
</div>
</div>
</ScreenshotProvider>

View File

@ -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<string, unknown> | 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<GooglePlaceResult | null> {
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<GooglePlaceResult | null> {
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<string, unknown>[] | 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<string, unknown>): GooglePlaceResult {
const displayName = place.displayName as Record<string, unknown> | undefined;
const openingHours = place.regularOpeningHours as Record<string, unknown> | null;
const reviews = (place.reviews as Record<string, unknown>[]) || [];
const primaryTypeDisplay = place.primaryTypeDisplayName as Record<string, unknown> | 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<string, unknown> | undefined;
const publishTime = r.publishTime as string || "";
return {
stars: (r.rating as number) || 0,
text: ((r.text as Record<string, unknown>)?.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<GooglePlaceResult | null> {
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<string, unknown>);
}

View File

@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null;
if (nb?.handle) {
captureTargets.push({ id: 'naver-blog', url: `https://blog.naver.com/${nb.handle}`, channel: '네이버 블로그', caption: '공식 네이버 블로그' });
}
// 네이버 플레이스
const np = verifiedChannels.naverPlace as Record<string, unknown> | null;
if (np?.url) {
captureTargets.push({ id: 'naver-place', url: np.url as string, channel: '네이버 플레이스', caption: '네이버 플레이스' });
}
// Google Maps
const gm = verifiedChannels.googleMaps as Record<string, unknown> | 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으로 추출해줘.`;

View File

@ -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<string, unknown> = {};
const analysisData: Record<string, unknown> = {};
@ -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<string, unknown>[]).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<string, unknown>[]).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<string, unknown> | 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<string, unknown>[]).length > 0) break;
}
const place = (items as Record<string, unknown>[])[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<string, unknown>[]) || []).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");

View File

@ -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<string, unknown> = {};
@ -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
const place = await searchGooglePlace(
clinicName || "",
address || undefined,
GOOGLE_PLACES_API_KEY,
);
if ((items as Record<string, unknown>[]).length > 0) break;
}
const place = (items as Record<string, unknown>[])[0];
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<string, unknown>[]) || [])
.slice(0, 10)
.map((r) => ({
stars: r.stars,
text: (r.text as string || "").slice(0, 200),
publishedAtDate: r.publishedAtDate,
})),
topReviews: place.topReviews,
};
}
})()

View File

@ -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<string, unknown> | 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<string, unknown[]>, ss: Record<string, unknown>) => {
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<string, unknown[]>);
}
// Instagram Posts/Reels 분석 (engagement rate, 콘텐츠 성과)
const igProfile = channelData.instagram as Record<string, unknown> | undefined;
const igPosts = channelData.instagramPosts as Record<string, unknown> | undefined;
const igReels = channelData.instagramReels as Record<string, unknown> | 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<string, number> = {};
const allPosts = [...((igPosts?.posts as Record<string, unknown>[]) || []), ...((igReels?.reels as Record<string, unknown>[]) || [])];
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<string, unknown>[]) || [])
.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<string, unknown>[]) || [])
.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