From 60cd0550422ce227444c452d9928f2845de69155 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 2 Apr 2026 10:43:53 +0900 Subject: [PATCH] feat: real API integration + YouTube Data API v3 + progressive loading - Replace mock useReport() with real Supabase API data pipeline - Add transformReport.ts to map API responses to MarketingReport type - Add useEnrichment() hook for background channel data enrichment - Replace Apify YouTube scraper with YouTube Data API v3 - Add mergeEnrichment() for progressive data loading - Add EmptyState component for graceful empty data handling - Add socialHandles to generate-report metadata - Graceful empty data in ClinicSnapshot, YouTube, Instagram, Facebook - Add Supabase Edge Functions and DB migrations - Add developer handoff documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/DESIGN_SYSTEM.md | 41 +- docs/DEVELOPER_HANDOFF.md | 508 ++++++++++++++++++ package-lock.json | 105 ++++ package.json | 1 + src/components/report/ClinicSnapshot.tsx | 88 +-- src/components/report/FacebookAudit.tsx | 4 + src/components/report/InstagramAudit.tsx | 14 +- src/components/report/YouTubeAudit.tsx | 12 + src/components/report/ui/EmptyState.tsx | 31 ++ src/hooks/useEnrichment.ts | 67 +++ src/hooks/useReport.ts | 71 ++- src/lib/supabase.ts | 80 +++ src/lib/transformReport.ts | 501 +++++++++++++++++ src/pages/AnalysisLoadingPage.tsx | 215 +++++--- src/pages/ReportPage.tsx | 49 +- supabase/.gitignore | 8 + supabase/config.toml | 440 +++++++++++++++ supabase/functions/analyze-market/.npmrc | 3 + supabase/functions/analyze-market/deno.json | 5 + supabase/functions/analyze-market/index.ts | 113 ++++ supabase/functions/enrich-channels/.npmrc | 3 + supabase/functions/enrich-channels/deno.json | 5 + supabase/functions/enrich-channels/index.ts | 262 +++++++++ supabase/functions/generate-report/.npmrc | 3 + supabase/functions/generate-report/deno.json | 5 + supabase/functions/generate-report/index.ts | 205 +++++++ supabase/functions/scrape-website/.npmrc | 3 + supabase/functions/scrape-website/deno.json | 5 + supabase/functions/scrape-website/index.ts | 160 ++++++ .../migrations/20260330_create_tables.sql | 40 ++ 30 files changed, 2905 insertions(+), 142 deletions(-) create mode 100644 docs/DEVELOPER_HANDOFF.md create mode 100644 src/components/report/ui/EmptyState.tsx create mode 100644 src/hooks/useEnrichment.ts create mode 100644 src/lib/supabase.ts create mode 100644 src/lib/transformReport.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/functions/analyze-market/.npmrc create mode 100644 supabase/functions/analyze-market/deno.json create mode 100644 supabase/functions/analyze-market/index.ts create mode 100644 supabase/functions/enrich-channels/.npmrc create mode 100644 supabase/functions/enrich-channels/deno.json create mode 100644 supabase/functions/enrich-channels/index.ts create mode 100644 supabase/functions/generate-report/.npmrc create mode 100644 supabase/functions/generate-report/deno.json create mode 100644 supabase/functions/generate-report/index.ts create mode 100644 supabase/functions/scrape-website/.npmrc create mode 100644 supabase/functions/scrape-website/deno.json create mode 100644 supabase/functions/scrape-website/index.ts create mode 100644 supabase/migrations/20260330_create_tables.sql diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index caeb94f..3dc407e 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -100,8 +100,8 @@ background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); | 용도 | 폰트 | Size | Weight | Tailwind Class | |------|-------|------|--------|---------------| | INFINITH 로고 | Playfair Display | `3xl` (30px) | Black 900 | `font-serif text-3xl font-black tracking-[0.05em]` | -| 페이지 H1 | Playfair Display | `4xl~5xl` | Bold 700 | `font-serif text-4xl md:text-5xl font-bold` | -| 섹션 타이틀 (영문) | Playfair Display | `3xl~4xl` | Bold 700 | `font-serif text-3xl md:text-4xl font-bold` | +| 페이지 H1 | Playfair Display | `5xl~7xl` | Bold 700 | `font-serif text-5xl md:text-7xl font-bold tracking-[-0.02em]` | +| 섹션 타이틀 (영문) | Playfair Display | `3xl~5xl` | Bold 700 | `font-serif text-3xl md:text-5xl font-bold` | | 섹션 서브타이틀 | Pretendard | `lg` (18px) | Regular 400 | `text-lg` | | 카드 제목 | Pretendard | `lg` (18px) | Bold 700 | `text-lg font-bold` | | 본문 | Pretendard / Inter | `sm~base` (14~16px) | Regular 400 | `text-sm` or `text-base` | @@ -235,6 +235,43 @@ shadow-2xl /* 라이트박스 모달 */ --- +## 9. Hero Section Specification + +### Layout & Spacing +| Property | Value | Tailwind Class | +|----------|-------|---------------| +| Top padding | 112px / 144px | `pt-28 md:pt-36` | +| Bottom padding | 48px / 64px | `pb-12 md:pb-16` | +| Content max-width | 896px | `max-w-4xl mx-auto` | + +**주의:** `min-h-screen` 사용 금지 — 다른 섹션과 일관된 간격 유지 (~160px gap between sections) + +### Content Structure +1. **Badge** — `PrismFilled` infinity loop icon + "Agentic AI Marketing Automation for Premium Medical Business & Marketing Agency" +2. **H1** — "Infinite Growth Marketing Engine." (`text-5xl md:text-7xl tracking-[-0.02em]`) +3. **Subtitle** — "Marketing that learns, improves, and accelerates — automatically. 쓸수록 더 정교해지는 AI 마케팅 엔진." +4. **CTA** — URL input + Analyze button (gradient `from-[#4F1DA1] to-[#021341]`) + +### Kerning Adjustments (Playfair Display ligature fix) +Playfair Display의 `fi` ligature가 겹침 발생 시 개별 문자에 `margin-left` 음수값 적용: +```tsx + + Inf + inite + +``` +**규칙:** `letter-spacing`은 문자 뒤에 적용되므로, 특정 문자 앞만 좁히려면 `margin-left` 사용 + +### Section Spacing Consistency +| Section Transition | Gap (approx.) | +|-------------------|---------------| +| Hero → TargetAudience | ~160px | +| TargetAudience → Problems | ~192px | +| Problems → Solution | ~192px | +| Solution → Modules | ~192px | + +--- + ## 7. PDF Export Rules | 규칙 | 구현 | diff --git a/docs/DEVELOPER_HANDOFF.md b/docs/DEVELOPER_HANDOFF.md new file mode 100644 index 0000000..1a7f9b2 --- /dev/null +++ b/docs/DEVELOPER_HANDOFF.md @@ -0,0 +1,508 @@ +# INFINITH — Developer Handoff Document +**Version:** 2.0 | **Updated:** 2026-04-01 | **Status:** Phase 1 Backend Complete, Frontend Integration Pending + +--- + +## 1. Architecture Overview + +``` +[Frontend: React + Vite + TailwindCSS] + │ + ├── src/lib/supabase.ts (API client) + │ + ▼ +[Supabase Edge Functions (Deno)] + │ + ├── generate-report ──── Orchestrator (Pipeline Entry) + │ │ + │ ├── 1) scrape-website ─── Firecrawl API + │ │ ├── /v1/scrape (구조화 JSON 추출) + │ │ ├── /v1/map (사이트맵 탐색) + │ │ └── /v1/search (리뷰 검색) + │ │ + │ ├── 2) analyze-market ─── Perplexity API (sonar) + │ │ ├── 경쟁 병원 분석 + │ │ ├── 키워드 트렌드 + │ │ ├── 시장 분석 + │ │ └── 타겟 오디언스 + │ │ + │ └── 3) AI 리포트 합성 ─── Perplexity API (sonar) + │ + ├── enrich-channels ──── Phase 2 (Background Enrichment) + │ ├── Instagram ─── Apify (instagram-profile-scraper) + │ ├── Google Maps ── Apify (crawler-google-places) + │ └── YouTube ───── Apify (youtube-channel-scraper) ⚠️ 수정 필요 + │ + ▼ +[Supabase PostgreSQL] + ├── scrape_results (스크래핑 캐시) + └── marketing_reports (최종 리포트) +``` + +--- + +## 2. Supabase Project + +| Item | Value | +|------|-------| +| **Project Ref** | `wkvjclkkonoxqtjxiwcw` | +| **Region** | Seoul (ap-northeast-2) | +| **Dashboard** | `https://supabase.com/dashboard/project/wkvjclkkonoxqtjxiwcw` | +| **API URL** | `https://wkvjclkkonoxqtjxiwcw.supabase.co` | +| **Edge Functions** | `https://wkvjclkkonoxqtjxiwcw.supabase.co/functions/v1/{function-name}` | +| **CLI** | `npx supabase` (글로벌 설치 불필요) | +| **Access Token** | `infinith-cli` (Supabase Dashboard > Account > Access Tokens) | + +### Database Tables + +```sql +-- supabase/migrations/20260330_create_tables.sql + +scrape_results ( + id UUID PRIMARY KEY, + url TEXT NOT NULL, + clinic_name TEXT, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +) + +marketing_reports ( + id UUID PRIMARY KEY, + url TEXT NOT NULL, + clinic_name TEXT, + report JSONB NOT NULL DEFAULT '{}', -- 최종 AI 리포트 + scrape_data JSONB DEFAULT '{}', -- 원본 스크래핑 데이터 + analysis_data JSONB DEFAULT '{}', -- 시장 분석 데이터 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +) +``` + +- RLS 활성화됨. `service_role` key로 Edge Function 호출 시 전체 접근 가능 +- `anon` role은 `marketing_reports` SELECT만 가능 + +### Supabase Secrets (Edge Functions 환경변수) + +Edge Function에서 `Deno.env.get()`으로 접근: + +```bash +# 시크릿 설정 명령어 +npx supabase secrets set FIRECRAWL_API_KEY=fc-cdae60d9535d46b086ee0f44a09ab185 +npx supabase secrets set PERPLEXITY_API_KEY=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 +npx supabase secrets set APIFY_API_TOKEN=apify_api_1ArgFPTjHhDxhyd9UkNVOF3WCABfA21GcXmv +npx supabase secrets set GEMINI_API_KEY=AIzaSyBUnPozy-crOLFwVepcamAnG8WWp2x1KZY + +# 자동 제공 (설정 불필요) +# SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY +``` + +--- + +## 3. Edge Functions — 상세 스펙 + +### 3.1 `scrape-website` + +| Item | Detail | +|------|--------| +| **파일** | `supabase/functions/scrape-website/index.ts` | +| **엔드포인트** | `POST /functions/v1/scrape-website` | +| **Auth** | `--no-verify-jwt` (인증 불필요) | +| **외부 API** | Firecrawl (`api.firecrawl.dev`) | +| **소요시간** | ~15-20s | + +**Request:** +```json +{ "url": "https://viewclinic.com", "clinicName": "뷰성형외과" } +``` + +**Response:** +```json +{ + "success": true, + "data": { + "clinic": { "clinicName", "address", "phone", "services[]", "doctors[]", "socialMedia{}" }, + "siteLinks": ["url1", "url2"], + "siteMap": ["url1", "url2"], + "reviews": [{ "title", "url", "description" }], + "scrapedAt": "ISO timestamp", + "sourceUrl": "https://viewclinic.com" + } +} +``` + +**처리 흐름:** +1. Firecrawl `/v1/scrape` — 메인 URL에서 병원 정보 구조화 추출 (JSON schema 정의됨) +2. Firecrawl `/v1/map` — 사이트 전체 페이지 URL 수집 (limit: 50) +3. Firecrawl `/v1/search` — "{병원명} 리뷰 평점 후기 강남언니 바비톡" 검색 + +--- + +### 3.2 `analyze-market` + +| Item | Detail | +|------|--------| +| **파일** | `supabase/functions/analyze-market/index.ts` | +| **엔드포인트** | `POST /functions/v1/analyze-market` | +| **외부 API** | Perplexity (`api.perplexity.ai`, model: `sonar`) | +| **소요시간** | ~10-15s (4개 쿼리 병렬) | + +**Request:** +```json +{ + "clinicName": "뷰성형외과", + "services": ["코성형", "눈성형", "리프팅"], + "address": "강남구", + "scrapeData": { ... } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "clinicName": "뷰성형외과", + "services": [...], + "address": "강남구", + "analysis": { + "competitors": { "data": {...}, "citations": [...] }, + "keywords": { "data": {...}, "citations": [...] }, + "market": { "data": {...}, "citations": [...] }, + "targetAudience": { "data": {...}, "citations": [...] } + }, + "analyzedAt": "ISO timestamp" + } +} +``` + +**4개 병렬 Perplexity 쿼리:** +1. `competitors` — 주변 경쟁 병원 5곳 (이름, 시술, 온라인 평판, 마케팅 채널) +2. `keywords` — 네이버/구글 검색 키워드 트렌드 20개 +3. `market` — 시장 규모, 성장률, 트렌드 분석 +4. `targetAudience` — 연령/성별/관심사/채널 분석 + +--- + +### 3.3 `generate-report` (Orchestrator) + +| Item | Detail | +|------|--------| +| **파일** | `supabase/functions/generate-report/index.ts` | +| **엔드포인트** | `POST /functions/v1/generate-report` | +| **외부 API** | 내부적으로 `scrape-website` + `analyze-market` + Perplexity 호출 | +| **소요시간** | ~45s (전체 파이프라인) | + +**Request:** +```json +{ "url": "https://viewclinic.com", "clinicName": "뷰성형외과" } +``` + +**Response:** +```json +{ + "success": true, + "reportId": "uuid", + "report": { + "clinicInfo": { ... }, + "executiveSummary": "경영진 요약", + "overallScore": 72, + "channelAnalysis": { + "naverBlog": { "score", "status", "posts", "recommendation" }, + "instagram": { "score", "status", "followers", "recommendation" }, + "youtube": { "score", "status", "subscribers", "recommendation" }, + "naverPlace": { "score", "rating", "reviews", "recommendation" }, + "gangnamUnni": { "score", "rating", "reviews", "recommendation" }, + "website": { "score", "issues[]", "recommendation" } + }, + "competitors": [{ "name", "strengths[]", "weaknesses[]", "marketingChannels[]" }], + "keywords": { + "primary": [{ "keyword", "monthlySearches", "competition" }], + "longTail": [{ "keyword", "monthlySearches" }] + }, + "targetAudience": { "primary": {...}, "secondary": {...} }, + "recommendations": [{ "priority", "category", "title", "description", "expectedImpact" }], + "marketTrends": [...] + }, + "metadata": { + "url": "...", + "clinicName": "...", + "generatedAt": "ISO timestamp", + "dataSources": { "scraping": true, "marketAnalysis": true, "aiGeneration": true } + } +} +``` + +**파이프라인 순서:** +1. `scrape-website` 호출 → 병원 데이터 수집 +2. `analyze-market` 호출 → 시장 분석 +3. Perplexity `sonar` 모델로 최종 리포트 JSON 합성 +4. `marketing_reports` 테이블에 저장 + +--- + +### 3.4 `enrich-channels` (Phase 2 — Background) + +| Item | Detail | +|------|--------| +| **파일** | `supabase/functions/enrich-channels/index.ts` | +| **엔드포인트** | `POST /functions/v1/enrich-channels` | +| **외부 API** | Apify Actors (3개 병렬) | +| **소요시간** | ~27s | + +**Request:** +```json +{ + "reportId": "uuid", + "clinicName": "뷰성형외과", + "instagramHandle": "viewplastic", + "youtubeChannelId": "@viewplastic", + "address": "강남구" +} +``` + +**Apify Actors 사용:** + +| Actor | Actor ID | 용도 | 검증 | +|-------|----------|------|------| +| Instagram Profile | `apify~instagram-profile-scraper` | 팔로워, 게시물, 바이오 | ✅ 정상 작동 (6s) | +| Google Maps | `compass~crawler-google-places` | 평점, 리뷰, 영업시간 | ✅ 정상 작동 (10s) | +| YouTube Channel | `streamers~youtube-channel-scraper` | 영상 목록, 조회수 | ⚠️ 빈 데이터 반환 — 다른 Actor 또는 YouTube Data API v3 필요 | + +**동작:** 기존 `marketing_reports`의 `report` 필드에 `channelEnrichment` 객체를 추가 저장 + +--- + +## 4. Frontend 통합 가이드 + +### 현재 상태 + +| 파일 | 상태 | 설명 | +|------|------|------| +| `src/lib/supabase.ts` | ✅ 완성 | `generateMarketingReport()`, `scrapeWebsite()` 함수 | +| `src/pages/AnalysisLoadingPage.tsx` | ✅ 완성 | 실제 API 호출 + 프로그레스 UI | +| `src/hooks/useReport.ts` | ⚠️ **Mock 데이터** | `mockReport` 반환 중 — 실제 API 연동 필요 | +| `src/pages/ReportPage.tsx` (or similar) | ⚠️ 확인 필요 | `useReport()` 결과 렌더링 | + +### 프론트엔드 환경변수 (`.env`) + +```env +VITE_SUPABASE_URL=https://wkvjclkkonoxqtjxiwcw.supabase.co +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs... +``` + +### API 호출 방식 + +Edge Functions는 `--no-verify-jwt`로 배포되어 있어 Authorization 헤더 불필요: + +```typescript +// src/lib/supabase.ts +const response = await fetch( + `${supabaseUrl}/functions/v1/generate-report`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, clinicName }), + } +); +``` + +### TODO: `useReport()` 실제 연동 + +`AnalysisLoadingPage`에서 `/report/view-clinic`으로 navigate할 때 `location.state`에 report 데이터를 전달 중: + +```typescript +navigate('/report/view-clinic', { + replace: true, + state: { report: result.report, metadata: result.metadata }, +}); +``` + +`useReport()` 훅에서 이 state를 받아 사용하도록 변경 필요. + +### TODO: Progressive Loading (Phase 2) + +``` +Phase 1 (~45s): generate-report → 즉시 리포트 표시 +Phase 2 (~27s): enrich-channels → 백그라운드에서 채널 데이터 보강 + → 완료 시 리포트 UI에 실시간 반영 +``` + +구현 방식: Phase 1 리포트 렌더 후 `enrich-channels` 비동기 호출 → Supabase Realtime 또는 polling으로 업데이트 감지 + +--- + +## 5. API & Service 연결 현황 + +### Connected (연결 완료, 코드 구현됨) + +| # | Service | 용도 | API Key 위치 | Dashboard | +|---|---------|------|-------------|-----------| +| 1 | **Firecrawl** | 웹사이트 스크래핑 (scrape/map/search) | `.env` + Supabase Secret | [Dashboard](https://www.firecrawl.dev/app) | +| 2 | **Perplexity** | 시장 분석 + 리포트 생성 (sonar) | `.env` + Supabase Secret | [API Settings](https://www.perplexity.ai/settings/api) | +| 3 | **Apify** | Instagram/Google Maps 채널 데이터 | `.env` + Supabase Secret | [Console](https://console.apify.com/) | +| 4 | **Supabase** | DB + Edge Functions + Auth | `.env` (VITE_*) | [Dashboard](https://supabase.com/dashboard/project/wkvjclkkonoxqtjxiwcw) | + +### Connected (MCP로 연결, 코드 미사용) + +| # | Service | 용도 | MCP Config | +|---|---------|------|-----------| +| 5 | **Gemini (nano-banana-pro)** | AI 이미지 생성/편집 | `claude_desktop_config.json` | +| 6 | **Figma MCP** | 디자인 에셋 읽기, 브랜드 변수 | VS Code Extension | +| 7 | **Slack MCP** | 팀 알림, 채널 메시징 | `claude_desktop_config.json` | +| 8 | **Notion MCP** | 문서/DB 관리 | VS Code Extension | +| 9 | **Google Drive** | 파일 저장/공유 | VS Code Extension | +| 10 | **Ahrefs** | SEO/키워드 분석 (GSC 연동) | VS Code Extension | +| 11 | **Claude in Chrome** | 브라우저 자동화 | Chrome Extension | +| 12 | **Firebase** | (미사용, 연결만) | VS Code Extension | + +### Not Connected (연동 필요) + +| # | Service | 용도 | 우선순위 | 문서 | +|---|---------|------|---------|------| +| 13 | **YouTube Data API v3** | 채널 통계, 영상 분석 | P0 | [Docs](https://developers.google.com/youtube/v3/getting-started) | +| 14 | **Naver Search API** | 블로그/카페/뉴스 검색 | P0 | [Developers](https://developers.naver.com/) | +| 15 | **Claude/Anthropic API** | AI 리포트 생성 (Perplexity 대체 가능) | P1 | [Docs](https://docs.anthropic.com/) | +| 16 | **Creatomate** | 템플릿 기반 영상/이미지 생성 | P1 | [API Docs](https://creatomate.com/docs/api/introduction) | +| 17 | **Instagram Graph API** | 공식 게시/인사이트 | P1 | [Docs](https://developers.facebook.com/docs/instagram-platform/) | +| 18 | **Google Search Console** | SEO 성과 추적 | P1 | [Docs](https://developers.google.com/webmaster-tools) | +| 19 | **Google Analytics 4** | 웹 트래픽 분석 | P1 | [Docs](https://developers.google.com/analytics/devguides/reporting/data/v1) | +| 20 | **Naver Place/Map API** | 플레이스 리뷰/위치 | P2 | [Naver Cloud](https://www.ncloud.com/) | +| 21 | **Google Maps Places API** | 구글 리뷰/평점 (Apify 대체중) | P2 | [Docs](https://developers.google.com/maps/documentation/places/web-service) | +| 22 | **TikTok API** | 숏폼 게시/분석 | P2 | [Docs](https://developers.tiktok.com/) | +| 23 | **Canva Connect API** | 템플릿 Autofill (Enterprise) | P2 | [Docs](https://www.canva.dev/docs/connect/) | +| 24 | **Brandfetch** | 브랜드 로고/컬러 추출 | P2 | [Docs](https://docs.brandfetch.com/) | + +--- + +## 6. 측정된 파이프라인 타이밍 + +``` +Phase 1: generate-report 전체 (~45초) + ├── scrape-website ~15-20s + ├── analyze-market ~10-15s (4 Perplexity 병렬) + └── AI report 합성 ~10-15s + +Phase 2: enrich-channels (~27초, 백그라운드) + ├── Instagram (Apify) ~6s + ├── Google Maps (Apify) ~10s + └── YouTube (Apify) ~11s (⚠️ 빈 데이터) + +Total: ~72초 (사용자 체감: 45초 → 리포트 표시) +``` + +--- + +## 7. 알려진 이슈 & 해결 필요 + +| # | 이슈 | 상세 | 해결 방향 | +|---|------|------|---------| +| 1 | **Gemini API 429** | 프로젝트 spending cap $10 초과. `generate-report`에서 Perplexity로 대체 완료 | AI Studio에서 한도 증가 또는 Perplexity 유지 | +| 2 | **YouTube Apify 빈 데이터** | `streamers~youtube-channel-scraper` Actor가 빈 배열 반환 | YouTube Data API v3 연동 또는 다른 Apify Actor 탐색 | +| 3 | **useReport() Mock** | `useReport()` 훅이 mockReport 반환 중 | `location.state`에서 실제 데이터 읽도록 변경 | +| 4 | **JWT 미검증** | Edge Functions가 `--no-verify-jwt`로 배포 | 프로덕션 전에 Supabase Auth 연동 + JWT 검증 활성화 | +| 5 | **Instagram 핸들 자동 감지** | `scrape-website`에서 추출한 socialMedia.instagram을 `enrich-channels`에 자동 전달 필요 | `generate-report` orchestrator에서 연결 | + +--- + +## 8. 프로젝트 구조 + +``` +remix_-infinith---infinite-marketing/ +├── src/ +│ ├── components/ +│ │ ├── Hero.tsx # 랜딩 히어로 (URL 입력) +│ │ ├── icons/ # 커스텀 아이콘 +│ │ └── ... +│ ├── hooks/ +│ │ └── useReport.ts # ⚠️ Mock 데이터 → 실제 연동 필요 +│ ├── lib/ +│ │ └── supabase.ts # ✅ Supabase 클라이언트 + API 함수 +│ ├── pages/ +│ │ ├── AnalysisLoadingPage.tsx # ✅ 분석 로딩 (실제 API 호출) +│ │ └── ... +│ └── ... +├── supabase/ +│ ├── functions/ +│ │ ├── scrape-website/ # ✅ Firecrawl 스크래핑 +│ │ ├── analyze-market/ # ✅ Perplexity 시장 분석 +│ │ ├── generate-report/ # ✅ 파이프라인 오케스트레이터 +│ │ └── enrich-channels/ # ✅ Apify 채널 enrichment +│ └── migrations/ +│ └── 20260330_create_tables.sql # DB 스키마 +├── docs/ +│ ├── API_CONNECTORS.md # API 레지스트리 (v1.0) +│ ├── DESIGN_SYSTEM.md # 디자인 시스템 +│ └── DEVELOPER_HANDOFF.md # 이 문서 +├── .env # 환경변수 (Git 제외) +└── package.json +``` + +--- + +## 9. Edge Functions 배포 가이드 + +```bash +# 1. Supabase CLI 로그인 +npx supabase login + +# 2. 프로젝트 연결 +npx supabase link --project-ref wkvjclkkonoxqtjxiwcw + +# 3. Secrets 설정 (최초 1회) +npx supabase secrets set FIRECRAWL_API_KEY=fc-cdae60d9535d46b086ee0f44a09ab185 +npx supabase secrets set PERPLEXITY_API_KEY=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 +npx supabase secrets set APIFY_API_TOKEN=apify_api_1ArgFPTjHhDxhyd9UkNVOF3WCABfA21GcXmv + +# 4. 개별 함수 배포 +npx supabase functions deploy scrape-website --no-verify-jwt +npx supabase functions deploy analyze-market --no-verify-jwt +npx supabase functions deploy generate-report --no-verify-jwt +npx supabase functions deploy enrich-channels --no-verify-jwt + +# 5. DB 마이그레이션 +npx supabase db push + +# 6. 로컬 테스트 +npx supabase functions serve --env-file .env +``` + +--- + +## 10. 개발 우선순위 로드맵 + +### Phase 1 — 즉시 (Frontend ↔ Backend 연결) + +- [ ] `useReport()` 훅을 실제 API 데이터로 교체 +- [ ] `generate-report` 호출 후 `enrich-channels` 자동 호출 연결 +- [ ] 리포트 페이지에서 `channelEnrichment` 데이터 렌더링 +- [ ] YouTube Data API v3 연동 (Apify 대체) +- [ ] 에러 핸들링 강화 (재시도, timeout, 사용자 피드백) + +### Phase 2 — Content Studio + +- [ ] Naver Search API 연동 (블로그/카페 검색) +- [ ] 콘텐츠 캘린더 생성 로직 (리포트 기반) +- [ ] Creatomate API 연동 (이미지/영상 생성) +- [ ] Gemini 이미지 생성 연동 (nano-banana-pro MCP) + +### Phase 3 — 자동화 & 배포 + +- [ ] Instagram Graph API (자동 게시) +- [ ] Supabase Auth 연동 (사용자 인증) +- [ ] Edge Function JWT 검증 활성화 +- [ ] Google Analytics / Search Console 연동 +- [ ] 자동 리포트 스케줄링 + +--- + +## 11. 테스트 데이터 + +검증에 사용한 실제 병원: + +| 병원 | URL | Instagram | 비고 | +|------|-----|-----------|------| +| 뷰성형외과 | `https://viewclinic.com` | `viewplastic` (14,094 followers) | 전체 파이프라인 테스트 완료 | + +--- + +*Last updated: 2026-04-01* diff --git a/package-lock.json b/package-lock.json index 28d5cb2..8e8f21d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@google/genai": "^1.29.0", + "@supabase/supabase-js": "^2.100.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", @@ -1184,6 +1185,92 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.1.tgz", + "integrity": "sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.100.1.tgz", + "integrity": "sha512-mo8QheoV4KR+wSubtyEWhZUxWnCM7YZ23TncccMAlbWAHb8YTDqRGRm9IalWCAswniKyud6buZCk9snRqI86KA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.100.1.tgz", + "integrity": "sha512-OIh4mOSo2LdqF2kox76OAPDtcSs+PwKABJOjc6plUV4/LXhFEsI2uwdEEIs7K7fd141qehWEVl/Y+Ts0fNvYsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.100.1.tgz", + "integrity": "sha512-FHuRWPX4qZQ4x+0Q+ZrKaBZnOiVGiwsgiAUJM98pYRib1yeaE/fOM1lZ1ozd+4gA8Udw23OyaD8SxKS5mT5NYw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.100.1.tgz", + "integrity": "sha512-x9xpEIoWM4xKiAlwfWTgHPSN6N4Y0aS4FVU4F6ZPbq7Gayw08SrtC6/YH/gOr8CjXQr0HxXYXDop2xGTSjubYA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.100.1.tgz", + "integrity": "sha512-CAeFm5sfX8sbTzxoxRafhohreIzl9a7R6qHTck3MrgTqm5M5g/u0IHfEKYzI9w/17r8NINl8UZrw2i08wrO7Iw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.100.1", + "@supabase/functions-js": "2.100.1", + "@supabase/postgrest-js": "2.100.1", + "@supabase/realtime-js": "2.100.1", + "@supabase/storage-js": "2.100.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1631,6 +1718,15 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2804,6 +2900,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", diff --git a/package.json b/package.json index b2f0c01..c910e77 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@google/genai": "^1.29.0", + "@supabase/supabase-js": "^2.100.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "better-sqlite3": "^12.4.1", diff --git a/src/components/report/ClinicSnapshot.tsx b/src/components/report/ClinicSnapshot.tsx index 4ff30c0..8c94639 100644 --- a/src/components/report/ClinicSnapshot.tsx +++ b/src/components/report/ClinicSnapshot.tsx @@ -14,15 +14,15 @@ function formatNumber(n: number): string { } const infoFields = (data: ClinicSnapshotType) => [ - { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar }, - { label: '의료진', value: `${data.staffCount}명`, icon: Users }, - { label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star }, - { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star }, - { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe }, - { label: '위치', value: `${data.location} (${data.nearestStation})`, icon: MapPin }, - { label: '전화', value: data.phone, icon: Phone }, - { label: '도메인', value: data.domain, icon: Globe }, -]; + data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null, + data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}명`, icon: Users } : null, + data.overallRating > 0 ? { label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star } : null, + data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null, + data.priceRange.min !== '-' ? { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe } : null, + data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null, + data.phone ? { label: '전화', value: data.phone, icon: Phone } : null, + data.domain ? { label: '도메인', value: data.domain, icon: Globe } : null, +].filter((f): f is NonNullable => f !== null); export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { const fields = infoFields(data); @@ -55,38 +55,46 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { })} - {/* Lead Doctor Highlight */} - -
- -

대표 원장

-
-

{data.leadDoctor.name}

-

{data.leadDoctor.credentials}

-
-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - {data.leadDoctor.rating} - + {/* Lead Doctor Highlight — only show if doctor name exists */} + {data.leadDoctor.name && ( + +
+ +

대표 원장

- - 리뷰 {formatNumber(data.leadDoctor.reviewCount)}건 - -
- +

{data.leadDoctor.name}

+ {data.leadDoctor.credentials && ( +

{data.leadDoctor.credentials}

+ )} + {data.leadDoctor.rating > 0 && ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {data.leadDoctor.rating} + +
+ {data.leadDoctor.reviewCount > 0 && ( + + 리뷰 {formatNumber(data.leadDoctor.reviewCount)}건 + + )} +
+ )} + + )} {/* Certifications */} {data.certifications.length > 0 && ( diff --git a/src/components/report/FacebookAudit.tsx b/src/components/report/FacebookAudit.tsx index b562617..249d5c9 100644 --- a/src/components/report/FacebookAudit.tsx +++ b/src/components/report/FacebookAudit.tsx @@ -325,6 +325,10 @@ function ConsolidationCard({ text }: { text: string }) { /* ─── Main Component ─── */ export default function FacebookAudit({ data }: { data: FacebookAuditType }) { + const hasData = data.pages.length > 0 || data.diagnosis.length > 0; + + if (!hasData) return null; + return ( {/* Page cards side by side */} diff --git a/src/components/report/InstagramAudit.tsx b/src/components/report/InstagramAudit.tsx index 7da82d2..659a9de 100644 --- a/src/components/report/InstagramAudit.tsx +++ b/src/components/report/InstagramAudit.tsx @@ -1,6 +1,7 @@ import { motion } from 'motion/react'; import { Instagram, AlertCircle, FileText, Users, Eye } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; +import { EmptyState } from './ui/EmptyState'; import { MetricCard } from './ui/MetricCard'; import { DiagnosisRow } from './ui/DiagnosisRow'; import type { InstagramAudit as InstagramAuditType, InstagramAccount } from '../../types/report'; @@ -107,14 +108,25 @@ function AccountCard({ account, index }: { key?: string | number; account: Insta } export default function InstagramAudit({ data }: InstagramAuditProps) { + const hasAccounts = data.accounts.length > 0 && data.accounts.some(a => a.handle || a.followers > 0); + return ( + {!hasAccounts && data.diagnosis.length === 0 && ( + + )} + {/* Account cards */} + {hasAccounts && (
{data.accounts.map((account, i) => ( - + ))}
+ )} {/* Diagnosis */} {data.diagnosis.length > 0 && ( diff --git a/src/components/report/YouTubeAudit.tsx b/src/components/report/YouTubeAudit.tsx index 426ad61..9d5a14b 100644 --- a/src/components/report/YouTubeAudit.tsx +++ b/src/components/report/YouTubeAudit.tsx @@ -1,6 +1,7 @@ import { motion } from 'motion/react'; import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; +import { EmptyState } from './ui/EmptyState'; import { MetricCard } from './ui/MetricCard'; import { DiagnosisRow } from './ui/DiagnosisRow'; import type { YouTubeAudit as YouTubeAuditType } from '../../types/report'; @@ -16,8 +17,18 @@ function formatNumber(n: number): string { } export default function YouTubeAudit({ data }: YouTubeAuditProps) { + const hasData = data.subscribers > 0 || data.totalVideos > 0 || data.topVideos.length > 0 || data.diagnosis.length > 0; + return ( + {!hasData && ( + + )} + + {hasData && <> {/* Metrics row */}
)} + } ); } diff --git a/src/components/report/ui/EmptyState.tsx b/src/components/report/ui/EmptyState.tsx new file mode 100644 index 0000000..9e05afb --- /dev/null +++ b/src/components/report/ui/EmptyState.tsx @@ -0,0 +1,31 @@ +import { motion } from 'motion/react'; +import { Search } from 'lucide-react'; + +interface EmptyStateProps { + message?: string; + subtext?: string; +} + +/** + * Shown inside report sections when data is not yet available + * (e.g., before enrichment completes or when a channel is not found). + */ +export function EmptyState({ + message = '데이터 수집 중', + subtext = '채널 데이터 보강이 완료되면 자동으로 업데이트됩니다.', +}: EmptyStateProps) { + return ( + +
+ +
+

{message}

+

{subtext}

+
+ ); +} diff --git a/src/hooks/useEnrichment.ts b/src/hooks/useEnrichment.ts new file mode 100644 index 0000000..ebb75e2 --- /dev/null +++ b/src/hooks/useEnrichment.ts @@ -0,0 +1,67 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { enrichChannels, fetchReportById } from '../lib/supabase'; +import { mergeEnrichment, type EnrichmentData } from '../lib/transformReport'; +import type { MarketingReport } from '../types/report'; + +type EnrichmentStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface UseEnrichmentResult { + status: EnrichmentStatus; + enrichedReport: MarketingReport | null; +} + +interface EnrichmentParams { + reportId: string | null; + clinicName: string; + instagramHandle?: string; + youtubeChannelId?: string; + address?: string; +} + +/** + * Triggers background channel enrichment after Phase 1 report renders. + * Fires once, waits for the Edge Function to complete (~27s), + * then returns the merged report. + */ +export function useEnrichment( + baseReport: MarketingReport | null, + params: EnrichmentParams | null, +): UseEnrichmentResult { + const [status, setStatus] = useState('idle'); + const [enrichedReport, setEnrichedReport] = useState(null); + const hasTriggered = useRef(false); + + useEffect(() => { + if (!baseReport || !params?.reportId || hasTriggered.current) return; + // Don't enrich if no social handles are available + if (!params.instagramHandle && !params.youtubeChannelId) return; + + hasTriggered.current = true; + setStatus('loading'); + + enrichChannels({ + reportId: params.reportId, + clinicName: params.clinicName, + instagramHandle: params.instagramHandle, + youtubeChannelId: params.youtubeChannelId, + address: params.address, + }) + .then((result) => { + if (result.success && result.data) { + const merged = mergeEnrichment(baseReport, result.data as EnrichmentData); + setEnrichedReport(merged); + setStatus('success'); + } else { + setStatus('error'); + } + }) + .catch(() => { + setStatus('error'); + }); + }, [baseReport, params]); + + return { + status, + enrichedReport, + }; +} diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts index a49e919..fcfdd6c 100644 --- a/src/hooks/useReport.ts +++ b/src/hooks/useReport.ts @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; +import { useLocation } from 'react-router'; import type { MarketingReport } from '../types/report'; -import { mockReport } from '../data/mockReport'; +import { fetchReportById } from '../lib/supabase'; +import { transformApiReport } from '../lib/transformReport'; interface UseReportResult { data: MarketingReport | null; @@ -8,27 +10,72 @@ interface UseReportResult { error: string | null; } +interface LocationState { + report?: Record; + metadata?: { + url: string; + clinicName: string; + generatedAt: string; + socialHandles?: Record; + address?: string; + services?: string[]; + }; + reportId?: string; +} + export function useReport(id: string | undefined): UseReportResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const location = useLocation(); useEffect(() => { - if (!id) { - setError('No report ID provided'); - setIsLoading(false); + const state = location.state as LocationState | undefined; + + // Source 1: Report data passed via navigation state (from AnalysisLoadingPage) + if (state?.report && state?.metadata) { + try { + const reportId = state.reportId || id || 'live'; + const transformed = transformApiReport( + reportId, + state.report, + state.metadata, + ); + setData(transformed); + setIsLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to parse report data'); + setIsLoading(false); + } return; } - // Phase 1: Return mock data immediately - // Phase 2+: Replace with real API call — fetch(`/api/reports/${id}`) - const timer = setTimeout(() => { - setData(mockReport); - setIsLoading(false); - }, 100); + // Source 2: Fetch from Supabase by report ID (bookmarked/shared link) + if (id && id !== 'view-clinic') { + fetchReportById(id) + .then((row) => { + const transformed = transformApiReport( + row.id, + row.report, + { + url: row.url, + clinicName: row.clinic_name || '', + generatedAt: row.created_at, + }, + ); + setData(transformed); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to fetch report'); + }) + .finally(() => setIsLoading(false)); + return; + } - return () => clearTimeout(timer); - }, [id]); + // No data source available + setError('리포트 데이터를 찾을 수 없습니다. 새 분석을 시작해주세요.'); + setIsLoading(false); + }, [id, location.state]); return { data, isLoading, error }; } diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..0f4ab89 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,80 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); + +export async function generateMarketingReport(url: string, clinicName?: string) { + const response = await fetch( + `${supabaseUrl}/functions/v1/generate-report`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, clinicName }), + } + ); + + if (!response.ok) { + throw new Error(`Report generation failed: ${response.statusText}`); + } + + return response.json(); +} + +export async function fetchReportById(reportId: string) { + const { data, error } = await supabase + .from("marketing_reports") + .select("*") + .eq("id", reportId) + .single(); + + if (error) throw new Error(`Failed to fetch report: ${error.message}`); + return data; +} + +export interface EnrichChannelsRequest { + reportId: string; + clinicName: string; + instagramHandle?: string; + youtubeChannelId?: string; + address?: string; +} + +/** + * Fire-and-forget: triggers background channel enrichment. + * Returns enrichment result when the Edge Function completes (~27s). + */ +export async function enrichChannels(params: EnrichChannelsRequest) { + const response = await fetch( + `${supabaseUrl}/functions/v1/enrich-channels`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + } + ); + + if (!response.ok) { + throw new Error(`Channel enrichment failed: ${response.statusText}`); + } + + return response.json(); +} + +export async function scrapeWebsite(url: string, clinicName?: string) { + const response = await fetch( + `${supabaseUrl}/functions/v1/scrape-website`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, clinicName }), + } + ); + + if (!response.ok) { + throw new Error(`Scraping failed: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts new file mode 100644 index 0000000..ce7cd8e --- /dev/null +++ b/src/lib/transformReport.ts @@ -0,0 +1,501 @@ +import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo } from '../types/report'; + +/** + * API response from generate-report Edge Function. + * The `report` field is AI-generated JSON with varying structure. + */ +interface ApiReport { + clinicInfo?: { + name?: string; + address?: string; + phone?: string; + services?: string[]; + doctors?: { name: string; specialty: string }[]; + }; + executiveSummary?: string; + overallScore?: number; + channelAnalysis?: Record; + competitors?: { + name: string; + strengths?: string[]; + weaknesses?: string[]; + marketingChannels?: string[]; + }[]; + keywords?: { + primary?: { keyword: string; monthlySearches?: number; competition?: string }[]; + longTail?: { keyword: string; monthlySearches?: number }[]; + }; + targetAudience?: { + primary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; + secondary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; + }; + recommendations?: { + priority?: string; + category?: string; + title?: string; + description?: string; + expectedImpact?: string; + }[]; + marketTrends?: string[]; +} + +interface ApiMetadata { + url: string; + clinicName: string; + generatedAt: string; + dataSources?: Record; +} + +function scoreToSeverity(score: number | undefined): Severity { + if (score === undefined) return 'unknown'; + if (score >= 80) return 'excellent'; + if (score >= 60) return 'good'; + if (score >= 40) return 'warning'; + return 'critical'; +} + +function statusToSeverity(status: string | undefined): Severity { + switch (status) { + case 'active': return 'good'; + case 'inactive': return 'critical'; + case 'weak': return 'warning'; + default: return 'unknown'; + } +} + +const CHANNEL_ICONS: Record = { + naverBlog: 'blog', + instagram: 'instagram', + youtube: 'youtube', + naverPlace: 'map', + gangnamUnni: 'star', + website: 'globe', + facebook: 'facebook', + tiktok: 'video', +}; + +function buildChannelScores(channels: ApiReport['channelAnalysis']): ChannelScore[] { + if (!channels) return []; + return Object.entries(channels).map(([key, ch]) => ({ + channel: key, + icon: CHANNEL_ICONS[key] || 'circle', + score: ch.score ?? 0, + maxScore: 100, + status: ch.score !== undefined ? scoreToSeverity(ch.score) : statusToSeverity(ch.status), + headline: ch.recommendation || '', + })); +} + +function buildDiagnosis(report: ApiReport): DiagnosisItem[] { + const items: DiagnosisItem[] = []; + + // Extract issues from channel analysis + if (report.channelAnalysis) { + for (const [channel, ch] of Object.entries(report.channelAnalysis)) { + if (ch.status === 'inactive' || ch.status === 'weak') { + items.push({ + category: channel, + detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`, + severity: statusToSeverity(ch.status), + }); + } + if (ch.issues) { + for (const issue of ch.issues) { + items.push({ category: channel, detail: issue, severity: 'warning' }); + } + } + } + } + + // Extract from recommendations + if (report.recommendations) { + for (const rec of report.recommendations) { + if (rec.priority === 'high') { + items.push({ + category: rec.category || '일반', + detail: `${rec.title}: ${rec.description}`, + severity: 'critical', + }); + } + } + } + + return items; +} + +/** + * Transform raw API response into the MarketingReport shape + * that frontend components expect. + */ +export function transformApiReport( + reportId: string, + apiReport: ApiReport, + metadata: ApiMetadata, +): MarketingReport { + const r = apiReport; + const clinic = r.clinicInfo || {}; + const doctor = clinic.doctors?.[0]; + + return { + id: reportId, + createdAt: metadata.generatedAt || new Date().toISOString(), + targetUrl: metadata.url, + overallScore: r.overallScore ?? 50, + + clinicSnapshot: { + name: clinic.name || metadata.clinicName || '', + nameEn: '', + established: '', + yearsInBusiness: 0, + staffCount: 0, + leadDoctor: { + name: doctor?.name || '', + credentials: doctor?.specialty || '', + rating: 0, + reviewCount: 0, + }, + overallRating: r.channelAnalysis?.gangnamUnni?.rating ?? 0, + totalReviews: r.channelAnalysis?.gangnamUnni?.reviews ?? 0, + priceRange: { min: '-', max: '-', currency: '₩' }, + certifications: [], + mediaAppearances: [], + medicalTourism: [], + location: clinic.address || '', + nearestStation: '', + phone: clinic.phone || '', + domain: new URL(metadata.url).hostname, + }, + + channelScores: buildChannelScores(r.channelAnalysis), + + youtubeAudit: { + channelName: clinic.name || '', + handle: '', + subscribers: r.channelAnalysis?.youtube?.subscribers ?? 0, + totalVideos: 0, + totalViews: 0, + weeklyViewGrowth: { absolute: 0, percentage: 0 }, + estimatedMonthlyRevenue: { min: 0, max: 0 }, + avgVideoLength: '-', + uploadFrequency: '-', + channelCreatedDate: '', + subscriberRank: '-', + channelDescription: '', + linkedUrls: [], + playlists: [], + topVideos: [], + diagnosis: (r.channelAnalysis?.youtube?.recommendation) + ? [{ category: 'YouTube', detail: r.channelAnalysis.youtube.recommendation, severity: scoreToSeverity(r.channelAnalysis.youtube.score) }] + : [], + }, + + instagramAudit: { + accounts: r.channelAnalysis?.instagram ? [{ + handle: '', + language: 'KR', + label: '메인', + posts: r.channelAnalysis.instagram.posts ?? 0, + followers: r.channelAnalysis.instagram.followers ?? 0, + following: 0, + category: '의료/건강', + profileLink: '', + highlights: [], + reelsCount: 0, + contentFormat: '', + profilePhoto: '', + bio: '', + }] : [], + diagnosis: (r.channelAnalysis?.instagram?.recommendation) + ? [{ category: 'Instagram', detail: r.channelAnalysis.instagram.recommendation, severity: scoreToSeverity(r.channelAnalysis.instagram.score) }] + : [], + }, + + facebookAudit: { + pages: [], + diagnosis: [], + brandInconsistencies: [], + consolidationRecommendation: '', + }, + + otherChannels: [ + ...(r.channelAnalysis?.naverBlog ? [{ + name: '네이버 블로그', + status: (r.channelAnalysis.naverBlog.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', + details: r.channelAnalysis.naverBlog.recommendation || '', + }] : []), + ...(r.channelAnalysis?.naverPlace ? [{ + name: '네이버 플레이스', + status: (r.channelAnalysis.naverPlace.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', + details: `평점: ${r.channelAnalysis.naverPlace.rating ?? '-'} / 리뷰: ${r.channelAnalysis.naverPlace.reviews ?? '-'}`, + }] : []), + ...(r.channelAnalysis?.gangnamUnni ? [{ + name: '강남언니', + status: (r.channelAnalysis.gangnamUnni.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', + details: `평점: ${r.channelAnalysis.gangnamUnni.rating ?? '-'} / 리뷰: ${r.channelAnalysis.gangnamUnni.reviews ?? '-'}`, + }] : []), + ], + + websiteAudit: { + primaryDomain: new URL(metadata.url).hostname, + additionalDomains: [], + snsLinksOnSite: false, + trackingPixels: [], + mainCTA: '', + }, + + problemDiagnosis: buildDiagnosis(r), + + transformation: { + brandIdentity: [], + contentStrategy: (r.recommendations || []) + .filter(rec => rec.category?.includes('콘텐츠') || rec.category?.includes('content')) + .map(rec => ({ + area: rec.title || '', + asIs: rec.description || '', + toBe: rec.expectedImpact || '', + })), + platformStrategies: buildChannelScores(r.channelAnalysis).map(ch => ({ + platform: ch.channel, + icon: ch.icon, + currentMetric: `점수: ${ch.score}`, + targetMetric: `목표: ${Math.min(ch.score + 20, 100)}`, + strategies: [], + })), + websiteImprovements: (r.channelAnalysis?.website?.issues || []).map(issue => ({ + area: '웹사이트', + asIs: issue, + toBe: '개선 필요', + })), + newChannelProposals: [], + }, + + roadmap: [1, 2, 3].map(month => ({ + month, + title: `${month}개월차`, + subtitle: month === 1 ? '기반 구축' : month === 2 ? '콘텐츠 강화' : '성과 최적화', + tasks: (r.recommendations || []) + .filter(rec => { + if (month === 1) return rec.priority === 'high'; + if (month === 2) return rec.priority === 'medium'; + return rec.priority === 'low'; + }) + .slice(0, 4) + .map(rec => ({ task: rec.title || rec.description || '', completed: false })), + })), + + kpiDashboard: [ + { metric: '종합 점수', current: `${r.overallScore ?? '-'}`, target3Month: `${Math.min((r.overallScore ?? 50) + 15, 100)}`, target12Month: `${Math.min((r.overallScore ?? 50) + 30, 100)}` }, + ...(r.channelAnalysis?.instagram ? [ + { metric: 'Instagram 팔로워', current: `${r.channelAnalysis.instagram.followers ?? 0}`, target3Month: `${Math.round((r.channelAnalysis.instagram.followers ?? 0) * 1.3)}`, target12Month: `${Math.round((r.channelAnalysis.instagram.followers ?? 0) * 2)}` }, + ] : []), + ...(r.channelAnalysis?.youtube ? [ + { metric: 'YouTube 구독자', current: `${r.channelAnalysis.youtube.subscribers ?? 0}`, target3Month: `${Math.round((r.channelAnalysis.youtube.subscribers ?? 0) * 1.5)}`, target12Month: `${Math.round((r.channelAnalysis.youtube.subscribers ?? 0) * 3)}` }, + ] : []), + ], + + screenshots: [], + }; +} + +/** + * Enrichment data shape from enrich-channels Edge Function. + */ +export interface EnrichmentData { + instagram?: { + username?: string; + followers?: number; + following?: number; + posts?: number; + bio?: string; + isBusinessAccount?: boolean; + externalUrl?: string; + latestPosts?: { + type?: string; + likes?: number; + comments?: number; + caption?: string; + timestamp?: string; + }[]; + }; + googleMaps?: { + name?: string; + rating?: number; + reviewCount?: number; + address?: string; + phone?: string; + website?: string; + category?: string; + openingHours?: unknown; + topReviews?: { + stars?: number; + text?: string; + publishedAtDate?: string; + }[]; + }; + youtube?: { + channelId?: string; + channelName?: string; + handle?: string; + description?: string; + publishedAt?: string; + thumbnailUrl?: string; + subscribers?: number; + totalViews?: number; + totalVideos?: number; + videos?: { + title?: string; + views?: number; + likes?: number; + comments?: number; + date?: string; + duration?: string; + url?: string; + thumbnail?: string; + }[]; + }; +} + +/** + * Merge enrichment data into an existing MarketingReport. + * Returns a new object — does not mutate the original. + */ +export function mergeEnrichment( + report: MarketingReport, + enrichment: EnrichmentData, +): MarketingReport { + const merged = { ...report }; + + // Instagram enrichment + if (enrichment.instagram) { + const ig = enrichment.instagram; + const existingAccount = merged.instagramAudit.accounts[0]; + + merged.instagramAudit = { + ...merged.instagramAudit, + accounts: [{ + handle: ig.username || existingAccount?.handle || '', + language: 'KR', + label: '메인', + posts: ig.posts ?? existingAccount?.posts ?? 0, + followers: ig.followers ?? existingAccount?.followers ?? 0, + following: ig.following ?? existingAccount?.following ?? 0, + category: '의료/건강', + profileLink: ig.username ? `https://instagram.com/${ig.username}` : '', + highlights: [], + reelsCount: 0, + contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정', + profilePhoto: '', + bio: ig.bio || '', + }], + }; + + // Update KPI with real follower data + merged.kpiDashboard = merged.kpiDashboard.map(kpi => + kpi.metric === 'Instagram 팔로워' && ig.followers + ? { + ...kpi, + current: `${ig.followers.toLocaleString()}`, + target3Month: `${Math.round(ig.followers * 1.3).toLocaleString()}`, + target12Month: `${Math.round(ig.followers * 2).toLocaleString()}`, + } + : kpi + ); + } + + // YouTube enrichment (YouTube Data API v3) + if (enrichment.youtube) { + const yt = enrichment.youtube; + const videos = yt.videos || []; + + // Parse ISO 8601 duration (PT1H2M3S) to readable format + const parseDuration = (iso?: string): string => { + if (!iso) return '-'; + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return iso; + const h = match[1] ? `${match[1]}:` : ''; + const m = match[2] || '0'; + const s = (match[3] || '0').padStart(2, '0'); + return h ? `${h}${m.padStart(2, '0')}:${s}` : `${m}:${s}`; + }; + + // Check if video is a Short (< 60 seconds) + const isShort = (iso?: string): boolean => { + if (!iso) return false; + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return false; + const totalSec = (parseInt(match[1] || '0') * 3600) + (parseInt(match[2] || '0') * 60) + parseInt(match[3] || '0'); + return totalSec <= 60; + }; + + merged.youtubeAudit = { + ...merged.youtubeAudit, + channelName: yt.channelName || merged.youtubeAudit.channelName, + handle: yt.handle || merged.youtubeAudit.handle, + subscribers: yt.subscribers ?? merged.youtubeAudit.subscribers, + totalVideos: yt.totalVideos ?? merged.youtubeAudit.totalVideos, + totalViews: yt.totalViews ?? merged.youtubeAudit.totalViews, + channelDescription: yt.description || merged.youtubeAudit.channelDescription, + channelCreatedDate: yt.publishedAt ? new Date(yt.publishedAt).toLocaleDateString('ko-KR') : merged.youtubeAudit.channelCreatedDate, + topVideos: videos.slice(0, 5).map((v): TopVideo => ({ + title: v.title || '', + views: v.views || 0, + uploadedAgo: v.date ? new Date(v.date).toLocaleDateString('ko-KR') : '', + type: isShort(v.duration) ? 'Short' : 'Long', + duration: parseDuration(v.duration), + })), + }; + + // Update KPI with real subscriber data + if (yt.subscribers) { + merged.kpiDashboard = merged.kpiDashboard.map(kpi => + kpi.metric === 'YouTube 구독자' + ? { + ...kpi, + current: `${yt.subscribers!.toLocaleString()}`, + target3Month: `${Math.round(yt.subscribers! * 1.5).toLocaleString()}`, + target12Month: `${Math.round(yt.subscribers! * 3).toLocaleString()}`, + } + : kpi + ); + } + } + + // Google Maps enrichment + if (enrichment.googleMaps) { + const gm = enrichment.googleMaps; + + merged.clinicSnapshot = { + ...merged.clinicSnapshot, + overallRating: gm.rating ?? merged.clinicSnapshot.overallRating, + totalReviews: gm.reviewCount ?? merged.clinicSnapshot.totalReviews, + phone: gm.phone || merged.clinicSnapshot.phone, + location: gm.address || merged.clinicSnapshot.location, + }; + + // Update or add Google Maps to otherChannels + const gmChannelIdx = merged.otherChannels.findIndex(c => c.name === '구글 지도'); + const gmChannel = { + name: '구글 지도', + status: 'active' as const, + details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`, + url: '', + }; + if (gmChannelIdx >= 0) { + merged.otherChannels[gmChannelIdx] = gmChannel; + } else { + merged.otherChannels = [...merged.otherChannels, gmChannel]; + } + } + + return merged; +} diff --git a/src/pages/AnalysisLoadingPage.tsx b/src/pages/AnalysisLoadingPage.tsx index 4ceb752..806677a 100644 --- a/src/pages/AnalysisLoadingPage.tsx +++ b/src/pages/AnalysisLoadingPage.tsx @@ -1,52 +1,82 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { motion } from 'motion/react'; -import { Check } from 'lucide-react'; +import { Check, AlertCircle } from 'lucide-react'; +import { generateMarketingReport } from '../lib/supabase'; const steps = [ - 'Scanning website...', - 'Capturing channel screenshots...', - 'Analyzing social media presence...', - 'Evaluating brand consistency...', - 'Generating intelligence report...', + { label: 'Scanning website...', key: 'scrape' }, + { label: 'Analyzing social media presence...', key: 'social' }, + { label: 'Researching competitors & keywords...', key: 'analyze' }, + { label: 'Generating AI marketing report...', key: 'generate' }, + { label: 'Finalizing report...', key: 'finalize' }, ]; export default function AnalysisLoadingPage() { const [currentStep, setCurrentStep] = useState(0); + const [error, setError] = useState(null); const navigate = useNavigate(); const location = useLocation(); const url = (location.state as { url?: string })?.url; + const hasStarted = useRef(false); useEffect(() => { - const timers: ReturnType[] = []; + if (hasStarted.current) return; + hasStarted.current = true; - steps.forEach((_, index) => { - timers.push( - setTimeout(() => { - setCurrentStep(index + 1); - }, (index + 1) * 1250) - ); - }); + if (!url) { + navigate('/', { replace: true }); + return; + } - timers.push( - setTimeout(() => { - navigate('/report/view-clinic', { replace: true }); - }, 5500) - ); + const runAnalysis = async () => { + try { + // Step 1: Scraping + setCurrentStep(1); - return () => timers.forEach(clearTimeout); - }, [navigate]); + // Simulate step progression while waiting for API + const stepTimer = setInterval(() => { + setCurrentStep((prev) => (prev < steps.length - 1 ? prev + 1 : prev)); + }, 5000); + + const result = await generateMarketingReport(url); + + clearInterval(stepTimer); + setCurrentStep(steps.length); + + if (result.success && result.report) { + const reportPath = result.reportId + ? `/report/${result.reportId}` + : '/report/live'; + + // Brief delay to show completion + setTimeout(() => { + navigate(reportPath, { + replace: true, + state: { + report: result.report, + metadata: result.metadata, + reportId: result.reportId, + }, + }); + }, 800); + } else { + throw new Error(result.error || 'Report generation failed'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }; + + runAnalysis(); + }, [url, navigate]); return (
- {/* Radial gradient overlay */}
- - {/* Purple glow blob */}
- {/* INFINITH logo text */} - {/* Entered URL */} {url && ( )} - {/* Analysis steps */} -
- {steps.map((step, index) => { - const isCompleted = currentStep > index; - const isActive = currentStep === index; - - return ( - - {/* Status icon */} -
- {isCompleted ? ( - - - - ) : isActive ? ( -
- ) : ( -
- )} -
- - {/* Step text */} - - {step} - - - ); - })} -
- - {/* Progress bar */} -
+ {error ? ( -
+ initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center" + > + +

{error}

+ + + ) : ( + <> +
+ {steps.map((step, index) => { + const isCompleted = currentStep > index; + const isActive = currentStep === index + 1 && currentStep <= steps.length; + + return ( + +
+ {isCompleted ? ( + + + + ) : isActive ? ( +
+ ) : ( +
+ )} +
+ + {step.label} + + + ); + })} +
+ +
+ +
+ +

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

+ + )}
); diff --git a/src/pages/ReportPage.tsx b/src/pages/ReportPage.tsx index ff335a2..0a68084 100644 --- a/src/pages/ReportPage.tsx +++ b/src/pages/ReportPage.tsx @@ -1,5 +1,7 @@ -import { useParams } from 'react-router'; +import { useMemo } from 'react'; +import { useParams, useLocation } from 'react-router'; import { useReport } from '../hooks/useReport'; +import { useEnrichment } from '../hooks/useEnrichment'; import { ReportNav } from '../components/report/ReportNav'; import { ScreenshotProvider } from '../contexts/ScreenshotContext'; @@ -25,14 +27,47 @@ const REPORT_SECTIONS = [ { id: 'facebook-audit', label: 'Facebook' }, { id: 'other-channels', label: '기타 채널' }, { id: 'problem-diagnosis', label: '문제 진단' }, - { id: 'transformation', label: '변환 전략' }, { id: 'roadmap', label: '로드맵' }, { id: 'kpi-dashboard', label: 'KPI' }, ]; export default function ReportPage() { const { id } = useParams<{ id: string }>(); - const { data, isLoading, error } = useReport(id); + const location = useLocation(); + const { data: baseData, isLoading, error } = useReport(id); + + // Extract enrichment params from location state (socialHandles from API) or base data + const enrichmentParams = useMemo(() => { + if (!baseData) return null; + + const state = location.state as Record | undefined; + const metadata = state?.metadata as Record | undefined; + const socialHandles = metadata?.socialHandles as Record | undefined; + + // Priority: API socialHandles > transformed data > undefined + const igHandle = + socialHandles?.instagram || + baseData.instagramAudit?.accounts?.[0]?.handle || + undefined; + + const ytHandle = + socialHandles?.youtube || + baseData.youtubeAudit?.handle || + undefined; + + return { + reportId: baseData.id, + clinicName: baseData.clinicSnapshot.name, + instagramHandle: igHandle || undefined, + youtubeChannelId: ytHandle || undefined, + address: baseData.clinicSnapshot.location || undefined, + }; + }, [baseData, location.state]); + + const { status: enrichStatus, enrichedReport } = useEnrichment(baseData, enrichmentParams); + + // Use enriched data when available, otherwise base data + const data = enrichedReport || baseData; if (isLoading) { return ( @@ -61,6 +96,14 @@ export default function ReportPage() {
+ {/* Enrichment status indicator */} + {enrichStatus === 'loading' && ( +
+
+ 채널 데이터 보강 중... +
+ )} +
/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./declarative" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + +[functions.scrape-website] +enabled = true +verify_jwt = true +import_map = "./functions/scrape-website/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/scrape-website/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/scrape-website/*.html" ] + +[functions.analyze-market] +enabled = true +verify_jwt = true +import_map = "./functions/analyze-market/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/analyze-market/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/analyze-market/*.html" ] + +[functions.generate-report] +enabled = true +verify_jwt = true +import_map = "./functions/generate-report/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/generate-report/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/generate-report/*.html" ] + +[functions.enrich-channels] +enabled = true +verify_jwt = true +import_map = "./functions/enrich-channels/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/enrich-channels/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/enrich-channels/*.html" ] diff --git a/supabase/functions/analyze-market/.npmrc b/supabase/functions/analyze-market/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/analyze-market/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/analyze-market/deno.json b/supabase/functions/analyze-market/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/analyze-market/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/analyze-market/index.ts b/supabase/functions/analyze-market/index.ts new file mode 100644 index 0000000..543150e --- /dev/null +++ b/supabase/functions/analyze-market/index.ts @@ -0,0 +1,113 @@ +import "@supabase/functions-js/edge-runtime.d.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +interface AnalyzeRequest { + clinicName: string; + services: string[]; + address: string; + scrapeData?: Record; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { clinicName, services, address } = + (await req.json()) as AnalyzeRequest; + + const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY"); + if (!PERPLEXITY_API_KEY) { + throw new Error("PERPLEXITY_API_KEY not configured"); + } + + // Run multiple Perplexity queries in parallel + const queries = [ + { + id: "competitors", + prompt: `${address} 근처 ${services.slice(0, 3).join(", ")} 전문 성형외과/피부과 경쟁 병원 5곳을 분석해줘. 각 병원의 이름, 주요 시술, 온라인 평판, 마케팅 채널(블로그, 인스타그램, 유튜브)을 포함해줘. JSON 형식으로 응답해줘.`, + }, + { + id: "keywords", + prompt: `한국 ${services.slice(0, 3).join(", ")} 관련 검색 키워드 트렌드를 분석해줘. 네이버와 구글에서 월간 검색량이 높은 키워드 20개, 경쟁 강도, 추천 롱테일 키워드를 JSON 형식으로 제공해줘.`, + }, + { + id: "market", + prompt: `한국 ${services[0] || "성형외과"} 시장 트렌드 2025-2026을 분석해줘. 시장 규모, 성장률, 주요 트렌드(비수술 시술 증가, AI 마케팅, 외국인 환자 유치 등), 마케팅 채널별 효과를 JSON 형식으로 제공해줘.`, + }, + { + id: "targetAudience", + prompt: `${clinicName || services[0] + " 병원"}의 잠재 고객을 분석해줘. 연령대별, 성별, 관심 시술, 정보 탐색 채널(강남언니, 바비톡, 네이버, 인스타), 의사결정 요인을 JSON 형식으로 제공해줘.`, + }, + ]; + + const perplexityResults = await Promise.allSettled( + queries.map(async (q) => { + const response = await fetch( + "https://api.perplexity.ai/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${PERPLEXITY_API_KEY}`, + }, + body: JSON.stringify({ + model: "sonar", + messages: [ + { + role: "system", + content: + "You are a Korean medical marketing analyst. Always respond in Korean. Provide data in valid JSON format when requested.", + }, + { role: "user", content: q.prompt }, + ], + temperature: 0.3, + }), + } + ); + const data = await response.json(); + return { + id: q.id, + content: data.choices?.[0]?.message?.content || "", + citations: data.citations || [], + }; + }) + ); + + const analysis: Record = {}; + for (const result of perplexityResults) { + if (result.status === "fulfilled") { + const { id, content, citations } = result.value; + let parsed = content; + const jsonMatch = content.match(/```json\n?([\s\S]*?)```/); + if (jsonMatch) { + try { + parsed = JSON.parse(jsonMatch[1]); + } catch { + // Keep as string if JSON parse fails + } + } + analysis[id] = { data: parsed, citations }; + } + } + + return new Response( + JSON.stringify({ + success: true, + data: { clinicName, services, address, analysis, analyzedAt: new Date().toISOString() }, + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + return new Response( + JSON.stringify({ success: false, error: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/supabase/functions/enrich-channels/.npmrc b/supabase/functions/enrich-channels/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/enrich-channels/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/enrich-channels/deno.json b/supabase/functions/enrich-channels/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/enrich-channels/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/enrich-channels/index.ts b/supabase/functions/enrich-channels/index.ts new file mode 100644 index 0000000..63d123d --- /dev/null +++ b/supabase/functions/enrich-channels/index.ts @@ -0,0 +1,262 @@ +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +const APIFY_BASE = "https://api.apify.com/v2"; + +interface EnrichRequest { + reportId: string; + clinicName: string; + instagramHandle?: string; + youtubeChannelId?: string; + address?: string; +} + +async function runApifyActor( + actorId: string, + input: Record, + token: string +): Promise { + const res = await fetch( + `${APIFY_BASE}/acts/${actorId}/runs?token=${token}&waitForFinish=120`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + } + ); + const run = await res.json(); + const datasetId = run.data?.defaultDatasetId; + if (!datasetId) return []; + + const itemsRes = await fetch( + `${APIFY_BASE}/datasets/${datasetId}/items?token=${token}&limit=20` + ); + return itemsRes.json(); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { reportId, clinicName, instagramHandle, youtubeChannelId, address } = + (await req.json()) as EnrichRequest; + + const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN"); + if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured"); + + const enrichment: Record = {}; + + // Run all enrichment tasks in parallel + const tasks = []; + + // 1. Instagram Profile + if (instagramHandle) { + tasks.push( + (async () => { + const items = await runApifyActor( + "apify~instagram-profile-scraper", + { usernames: [instagramHandle], resultsLimit: 12 }, + APIFY_TOKEN + ); + const profile = (items as Record[])[0]; + if (profile && !profile.error) { + enrichment.instagram = { + username: profile.username, + followers: profile.followersCount, + following: profile.followsCount, + posts: profile.postsCount, + bio: profile.biography, + isBusinessAccount: profile.isBusinessAccount, + externalUrl: profile.externalUrl, + latestPosts: ((profile.latestPosts as Record[]) || []) + .slice(0, 12) + .map((p) => ({ + type: p.type, + likes: p.likesCount, + comments: p.commentsCount, + caption: (p.caption as string || "").slice(0, 200), + timestamp: p.timestamp, + })), + }; + } + })() + ); + } + + // 2. Google Maps / Place Reviews + if (clinicName || address) { + tasks.push( + (async () => { + const searchQuery = `${clinicName} ${address || "강남"}`; + const items = await runApifyActor( + "compass~crawler-google-places", + { + searchStringsArray: [searchQuery], + maxCrawledPlacesPerSearch: 1, + language: "ko", + maxReviews: 10, + }, + APIFY_TOKEN + ); + const place = (items as Record[])[0]; + if (place) { + enrichment.googleMaps = { + name: place.title, + rating: place.totalScore, + reviewCount: place.reviewsCount, + address: place.address, + phone: place.phone, + website: place.website, + category: place.categoryName, + openingHours: place.openingHours, + topReviews: ((place.reviews as Record[]) || []) + .slice(0, 10) + .map((r) => ({ + stars: r.stars, + text: (r.text as string || "").slice(0, 200), + publishedAtDate: r.publishedAtDate, + })), + }; + } + })() + ); + } + + // 3. YouTube Channel (using YouTube Data API v3) + if (youtubeChannelId) { + const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY"); + if (YOUTUBE_API_KEY) { + tasks.push( + (async () => { + const YT_BASE = "https://www.googleapis.com/youtube/v3"; + + // Resolve handle/username to channel ID + let channelId = youtubeChannelId; + if (channelId.startsWith("@") || !channelId.startsWith("UC")) { + // Use forHandle for @handles, forUsername for legacy usernames + const param = channelId.startsWith("@") ? "forHandle" : "forUsername"; + const handle = channelId.startsWith("@") ? channelId.slice(1) : channelId; + const lookupRes = await fetch( + `${YT_BASE}/channels?part=id&${param}=${handle}&key=${YOUTUBE_API_KEY}` + ); + const lookupData = await lookupRes.json(); + channelId = lookupData.items?.[0]?.id || ""; + } + + if (!channelId) return; + + // Step 1: Get channel statistics & snippet (1 quota unit) + const channelRes = await fetch( + `${YT_BASE}/channels?part=snippet,statistics,brandingSettings&id=${channelId}&key=${YOUTUBE_API_KEY}` + ); + const channelData = await channelRes.json(); + const channel = channelData.items?.[0]; + + if (!channel) return; + + const stats = channel.statistics || {}; + const snippet = channel.snippet || {}; + + // Step 2: Get recent/popular videos (100 quota units) + const searchRes = await fetch( + `${YT_BASE}/search?part=snippet&channelId=${channelId}&order=viewCount&type=video&maxResults=10&key=${YOUTUBE_API_KEY}` + ); + const searchData = await searchRes.json(); + const videoIds = (searchData.items || []) + .map((item: Record) => (item.id as Record)?.videoId) + .filter(Boolean) + .join(","); + + // Step 3: Get video details — views, likes, duration (1 quota unit) + let videos: Record[] = []; + if (videoIds) { + const videosRes = await fetch( + `${YT_BASE}/videos?part=snippet,statistics,contentDetails&id=${videoIds}&key=${YOUTUBE_API_KEY}` + ); + const videosData = await videosRes.json(); + videos = videosData.items || []; + } + + enrichment.youtube = { + channelId, + channelName: snippet.title, + handle: snippet.customUrl || youtubeChannelId, + description: snippet.description?.slice(0, 500), + publishedAt: snippet.publishedAt, + thumbnailUrl: snippet.thumbnails?.default?.url, + subscribers: parseInt(stats.subscriberCount || "0", 10), + totalViews: parseInt(stats.viewCount || "0", 10), + totalVideos: parseInt(stats.videoCount || "0", 10), + videos: videos.slice(0, 10).map((v) => { + const vs = v.statistics as Record || {}; + const vSnippet = v.snippet as Record || {}; + const vContent = v.contentDetails as Record || {}; + return { + title: vSnippet.title, + views: parseInt(vs.viewCount || "0", 10), + likes: parseInt(vs.likeCount || "0", 10), + comments: parseInt(vs.commentCount || "0", 10), + date: vSnippet.publishedAt, + duration: vContent.duration, + url: `https://www.youtube.com/watch?v=${(v.id as string)}`, + thumbnail: (vSnippet.thumbnails as Record>)?.medium?.url, + }; + }), + }; + })() + ); + } + } + + await Promise.allSettled(tasks); + + // Save enrichment data to Supabase + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const supabase = createClient(supabaseUrl, supabaseKey); + + if (reportId) { + // Get existing report + const { data: existing } = await supabase + .from("marketing_reports") + .select("report") + .eq("id", reportId) + .single(); + + if (existing) { + const updatedReport = { + ...existing.report, + channelEnrichment: enrichment, + enrichedAt: new Date().toISOString(), + }; + + await supabase + .from("marketing_reports") + .update({ report: updatedReport, updated_at: new Date().toISOString() }) + .eq("id", reportId); + } + } + + return new Response( + JSON.stringify({ + success: true, + data: enrichment, + enrichedAt: new Date().toISOString(), + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + return new Response( + JSON.stringify({ success: false, error: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/supabase/functions/generate-report/.npmrc b/supabase/functions/generate-report/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/generate-report/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/generate-report/deno.json b/supabase/functions/generate-report/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/generate-report/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts new file mode 100644 index 0000000..d4934b0 --- /dev/null +++ b/supabase/functions/generate-report/index.ts @@ -0,0 +1,205 @@ +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +interface ReportRequest { + url: string; + clinicName?: string; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { url, clinicName } = (await req.json()) as ReportRequest; + + if (!url) { + return new Response( + JSON.stringify({ error: "URL is required" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY"); + if (!PERPLEXITY_API_KEY) { + throw new Error("PERPLEXITY_API_KEY not configured"); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL")!; + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + + // Step 1: Call scrape-website function + const scrapeRes = await fetch(`${supabaseUrl}/functions/v1/scrape-website`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${supabaseKey}`, + }, + body: JSON.stringify({ url, clinicName }), + }); + const scrapeResult = await scrapeRes.json(); + + if (!scrapeResult.success) { + throw new Error(`Scraping failed: ${scrapeResult.error}`); + } + + const clinic = scrapeResult.data.clinic; + const resolvedName = clinicName || clinic.clinicName || url; + const services = clinic.services || []; + const address = clinic.address || ""; + + // Step 2: Call analyze-market function + const analyzeRes = await fetch(`${supabaseUrl}/functions/v1/analyze-market`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${supabaseKey}`, + }, + body: JSON.stringify({ + clinicName: resolvedName, + services, + address, + scrapeData: scrapeResult.data, + }), + }); + const analyzeResult = await analyzeRes.json(); + + // Step 3: Generate final report with Gemini + const reportPrompt = ` +당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요. + +## 수집된 데이터 + +### 병원 정보 +${JSON.stringify(scrapeResult.data, null, 2)} + +### 시장 분석 +${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)} + +## 리포트 형식 (반드시 아래 JSON 구조로 응답) + +{ + "clinicInfo": { + "name": "병원명", + "address": "주소", + "phone": "전화번호", + "services": ["시술1", "시술2"], + "doctors": [{"name": "의사명", "specialty": "전문분야"}] + }, + "executiveSummary": "경영진 요약 (3-5문장)", + "overallScore": 0-100, + "channelAnalysis": { + "naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항" }, + "instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항" }, + "youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항" }, + "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" }, + "gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" }, + "website": { "score": 0-100, "issues": [], "recommendation": "추천사항" } + }, + "competitors": [ + { "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] } + ], + "keywords": { + "primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}], + "longTail": [{"keyword": "롱테일 키워드", "monthlySearches": 0}] + }, + "targetAudience": { + "primary": { "ageRange": "25-35", "gender": "female", "interests": ["관심사1"], "channels": ["채널1"] }, + "secondary": { "ageRange": "35-45", "gender": "female", "interests": ["관심사1"], "channels": ["채널1"] } + }, + "recommendations": [ + { "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" } + ], + "marketTrends": ["트렌드1", "트렌드2"] +} +`; + + const aiRes = await fetch("https://api.perplexity.ai/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${PERPLEXITY_API_KEY}`, + }, + body: JSON.stringify({ + model: "sonar", + messages: [ + { + role: "system", + content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Always respond in Korean for text fields.", + }, + { role: "user", content: reportPrompt }, + ], + temperature: 0.3, + }), + }); + + const aiData = await aiRes.json(); + let reportText = aiData.choices?.[0]?.message?.content || ""; + // Strip markdown code blocks if present + const jsonMatch = reportText.match(/```(?:json)?\n?([\s\S]*?)```/); + if (jsonMatch) reportText = jsonMatch[1]; + + let report; + try { + report = JSON.parse(reportText); + } catch { + report = { raw: reportText, parseError: true }; + } + + // Save to Supabase + const supabase = createClient(supabaseUrl, supabaseKey); + const { data: saved, error: saveError } = await supabase + .from("marketing_reports") + .insert({ + url, + clinic_name: resolvedName, + report, + scrape_data: scrapeResult.data, + analysis_data: analyzeResult.data, + }) + .select("id") + .single(); + + // Extract social handles from scrape data for frontend enrichment + const socialMedia = clinic.socialMedia || {}; + + return new Response( + JSON.stringify({ + success: true, + reportId: saved?.id || null, + report, + metadata: { + url, + clinicName: resolvedName, + generatedAt: new Date().toISOString(), + dataSources: { + scraping: scrapeResult.success, + marketAnalysis: analyzeResult.success, + aiGeneration: !report.parseError, + }, + socialHandles: { + instagram: socialMedia.instagram || null, + youtube: socialMedia.youtube || null, + facebook: socialMedia.facebook || null, + blog: socialMedia.blog || null, + }, + address, + services, + }, + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + return new Response( + JSON.stringify({ success: false, error: error.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/supabase/functions/scrape-website/.npmrc b/supabase/functions/scrape-website/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/scrape-website/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/scrape-website/deno.json b/supabase/functions/scrape-website/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/scrape-website/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/scrape-website/index.ts b/supabase/functions/scrape-website/index.ts new file mode 100644 index 0000000..5c00433 --- /dev/null +++ b/supabase/functions/scrape-website/index.ts @@ -0,0 +1,160 @@ +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", +}; + +interface ScrapeRequest { + url: string; + clinicName?: string; +} + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const { url, clinicName } = (await req.json()) as ScrapeRequest; + + if (!url) { + return new Response( + JSON.stringify({ error: "URL is required" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY"); + if (!FIRECRAWL_API_KEY) { + throw new Error("FIRECRAWL_API_KEY not configured"); + } + + // Step 1: Scrape the main website + const scrapeResponse = await fetch("https://api.firecrawl.dev/v1/scrape", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify({ + url, + formats: ["json", "links"], + jsonOptions: { + prompt: + "Extract clinic information: clinic name, address, phone number, services offered, doctors with specialties, social media links (instagram, youtube, blog, facebook), business hours, and any marketing-related content like slogans or key messages", + schema: { + type: "object", + properties: { + clinicName: { type: "string" }, + address: { type: "string" }, + phone: { type: "string" }, + businessHours: { type: "string" }, + slogan: { type: "string" }, + services: { + type: "array", + items: { type: "string" }, + }, + doctors: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + title: { type: "string" }, + specialty: { type: "string" }, + }, + }, + }, + socialMedia: { + type: "object", + properties: { + instagram: { type: "string" }, + youtube: { type: "string" }, + blog: { type: "string" }, + facebook: { type: "string" }, + }, + }, + }, + }, + }, + waitFor: 5000, + }), + }); + + const scrapeData = await scrapeResponse.json(); + + if (!scrapeData.success) { + throw new Error(scrapeData.error || "Scraping failed"); + } + + // Step 2: Map the site to discover all pages + const mapResponse = await fetch("https://api.firecrawl.dev/v1/map", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify({ + url, + limit: 50, + }), + }); + + const mapData = await mapResponse.json(); + + // Step 3: Search for reviews and ratings + const searchName = clinicName || scrapeData.data?.json?.clinicName || url; + const searchResponse = await fetch("https://api.firecrawl.dev/v1/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify({ + query: `${searchName} 리뷰 평점 후기 강남언니 바비톡`, + limit: 5, + }), + }); + + const searchData = await searchResponse.json(); + + // Combine all data + const result = { + clinic: scrapeData.data?.json || {}, + siteLinks: scrapeData.data?.links || [], + siteMap: mapData.success ? mapData.links || [] : [], + reviews: searchData.data || [], + scrapedAt: new Date().toISOString(), + sourceUrl: url, + }; + + // Save to Supabase if configured + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + + if (supabaseUrl && supabaseKey) { + const supabase = createClient(supabaseUrl, supabaseKey); + await supabase.from("scrape_results").insert({ + url, + clinic_name: result.clinic.clinicName || searchName, + data: result, + }); + } + + return new Response(JSON.stringify({ success: true, data: result }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response( + JSON.stringify({ success: false, error: error.message }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } +}); diff --git a/supabase/migrations/20260330_create_tables.sql b/supabase/migrations/20260330_create_tables.sql new file mode 100644 index 0000000..69e276c --- /dev/null +++ b/supabase/migrations/20260330_create_tables.sql @@ -0,0 +1,40 @@ +-- Scrape results cache +CREATE TABLE IF NOT EXISTS scrape_results ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + url TEXT NOT NULL, + clinic_name TEXT, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Marketing intelligence reports +CREATE TABLE IF NOT EXISTS marketing_reports ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + url TEXT NOT NULL, + clinic_name TEXT, + report JSONB NOT NULL DEFAULT '{}', + scrape_data JSONB DEFAULT '{}', + analysis_data JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Enable RLS +ALTER TABLE scrape_results ENABLE ROW LEVEL SECURITY; +ALTER TABLE marketing_reports ENABLE ROW LEVEL SECURITY; + +-- Service role can do everything (Edge Functions use service_role key) +CREATE POLICY "service_role_all_scrape" ON scrape_results + FOR ALL USING (auth.role() = 'service_role'); + +CREATE POLICY "service_role_all_reports" ON marketing_reports + FOR ALL USING (auth.role() = 'service_role'); + +-- Anon users can read their own reports (future: add user_id column) +CREATE POLICY "anon_read_reports" ON marketing_reports + FOR SELECT USING (true); + +-- Index for faster lookups +CREATE INDEX idx_scrape_results_url ON scrape_results(url); +CREATE INDEX idx_marketing_reports_url ON marketing_reports(url); +CREATE INDEX idx_marketing_reports_created ON marketing_reports(created_at DESC);