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) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-02 10:43:53 +09:00
parent c6e18b6a67
commit 60cd055042
30 changed files with 2905 additions and 142 deletions

View File

@ -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
<span className="tracking-[0.05em]">
In<span className="ml-[-0.04em]">f</span>
<span className="ml-[-0.04em]">inite</span>
</span>
```
**규칙:** `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
| 규칙 | 구현 |

508
docs/DEVELOPER_HANDOFF.md Normal file
View File

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

105
package-lock.json generated
View File

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

View File

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

View File

@ -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<typeof f> => f !== null);
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
@ -55,7 +55,8 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
})}
</div>
{/* Lead Doctor Highlight */}
{/* Lead Doctor Highlight — only show if doctor name exists */}
{data.leadDoctor.name && (
<motion.div
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
@ -68,7 +69,10 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
{data.leadDoctor.credentials && (
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
)}
{data.leadDoctor.rating > 0 && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
@ -82,11 +86,15 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
{data.leadDoctor.rating}
</span>
</div>
{data.leadDoctor.reviewCount > 0 && (
<span className="text-sm text-slate-500">
{formatNumber(data.leadDoctor.reviewCount)}
</span>
)}
</div>
)}
</motion.div>
)}
{/* Certifications */}
{data.certifications.length > 0 && (

View File

@ -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 (
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
{/* Page cards side by side */}

View File

@ -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 (
<SectionWrapper id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
{!hasAccounts && data.diagnosis.length === 0 && (
<EmptyState
message="Instagram 계정 데이터 수집 중"
subtext="채널 데이터 보강이 완료되면 계정 정보와 분석 결과가 표시됩니다."
/>
)}
{/* Account cards */}
{hasAccounts && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{data.accounts.map((account, i) => (
<AccountCard key={account.handle} account={account} index={i} />
<AccountCard key={account.handle || i} account={account} index={i} />
))}
</div>
)}
{/* Diagnosis */}
{data.diagnosis.length > 0 && (

View File

@ -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 (
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
{!hasData && (
<EmptyState
message="YouTube 채널 데이터 수집 중"
subtext="채널 데이터 보강이 완료되면 구독자, 영상, 조회수 정보가 표시됩니다."
/>
)}
{hasData && <>
{/* Metrics row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<MetricCard
@ -172,6 +183,7 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
))}
</motion.div>
)}
</>}
</SectionWrapper>
);
}

View File

@ -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 (
<motion.div
className="flex flex-col items-center justify-center py-12 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<div className="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
<Search size={20} className="text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-500">{message}</p>
<p className="text-xs text-slate-400 mt-1 max-w-xs">{subtext}</p>
</motion.div>
);
}

View File

@ -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<EnrichmentStatus>('idle');
const [enrichedReport, setEnrichedReport] = useState<MarketingReport | null>(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,
};
}

View File

@ -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<string, unknown>;
metadata?: {
url: string;
clinicName: string;
generatedAt: string;
socialHandles?: Record<string, string | null>;
address?: string;
services?: string[];
};
reportId?: string;
}
export function useReport(id: string | undefined): UseReportResult {
const [data, setData] = useState<MarketingReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const location = useLocation();
useEffect(() => {
if (!id) {
setError('No report ID provided');
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 };
}

80
src/lib/supabase.ts Normal file
View File

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

501
src/lib/transformReport.ts Normal file
View File

@ -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<string, {
score?: number;
status?: string;
posts?: number;
followers?: number;
subscribers?: number;
rating?: number;
reviews?: number;
issues?: string[];
recommendation?: string;
}>;
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<string, boolean>;
}
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<string, string> = {
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;
}

View File

@ -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<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
const url = (location.state as { url?: string })?.url;
const hasStarted = useRef(false);
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
if (hasStarted.current) return;
hasStarted.current = true;
steps.forEach((_, index) => {
timers.push(
if (!url) {
navigate('/', { replace: true });
return;
}
const runAnalysis = async () => {
try {
// Step 1: Scraping
setCurrentStep(1);
// 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(() => {
setCurrentStep(index + 1);
}, (index + 1) * 1250)
);
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');
}
};
timers.push(
setTimeout(() => {
navigate('/report/view-clinic', { replace: true });
}, 5500)
);
return () => timers.forEach(clearTimeout);
}, [navigate]);
runAnalysis();
}, [url, navigate]);
return (
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
{/* Radial gradient overlay */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_rgba(79,29,161,0.25)_0%,_transparent_70%)]" />
{/* Purple glow blob */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-purple-600/20 rounded-full blur-[120px] pointer-events-none" />
<div className="relative z-10 flex flex-col items-center w-full max-w-lg">
{/* INFINITH logo text */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -56,7 +86,6 @@ export default function AnalysisLoadingPage() {
INFINITH
</motion.h1>
{/* Entered URL */}
{url && (
<motion.p
initial={{ opacity: 0, y: 20 }}
@ -68,21 +97,36 @@ export default function AnalysisLoadingPage() {
</motion.p>
)}
{/* Analysis steps */}
{error ? (
<motion.div
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"
>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 text-sm mb-4">{error}</p>
<button
onClick={() => navigate('/', { replace: true })}
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
>
Try Again
</button>
</motion.div>
) : (
<>
<div className="w-full space-y-5 mb-14">
{steps.map((step, index) => {
const isCompleted = currentStep > index;
const isActive = currentStep === index;
const isActive = currentStep === index + 1 && currentStep <= steps.length;
return (
<motion.div
key={step}
key={step.key}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
transition={{ duration: 0.4, delay: index * 0.15 }}
className="flex items-center gap-4"
>
{/* Status icon */}
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
{isCompleted ? (
<motion.div
@ -99,8 +143,6 @@ export default function AnalysisLoadingPage() {
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
)}
</div>
{/* Step text */}
<span
className={`text-base font-sans transition-colors duration-300 ${
isCompleted
@ -110,22 +152,27 @@ export default function AnalysisLoadingPage() {
: 'text-white/30'
}`}
>
{step}
{step.label}
</span>
</motion.div>
);
})}
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: '0%' }}
animate={{ width: '100%' }}
transition={{ duration: 5, ease: 'linear' }}
animate={{ width: `${(currentStep / steps.length) * 100}%` }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
/>
</div>
<p className="text-white/40 text-xs mt-4">
AI . 20~30 .
</p>
</>
)}
</div>
</div>
);

View File

@ -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<string, unknown> | undefined;
const metadata = state?.metadata as Record<string, unknown> | undefined;
const socialHandles = metadata?.socialHandles as Record<string, string | null> | 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() {
<div className="pt-20">
<ReportNav sections={REPORT_SECTIONS} />
{/* Enrichment status indicator */}
{enrichStatus === 'loading' && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] border border-slate-100">
<div className="w-4 h-4 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-slate-500"> ...</span>
</div>
)}
<div data-report-content>
<ReportHeader
overallScore={data.overallScore}

8
supabase/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

440
supabase/config.toml Normal file
View File

@ -0,0 +1,440 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "remix_-infinith---infinite-marketing"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/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://<database>/<schema>/<hook_name>"
# 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. <bucket_name>.s3-<region>.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" ]

View File

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

View File

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}

View File

@ -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<string, unknown>;
}
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<string, unknown> = {};
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" } }
);
}
});

View File

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

View File

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}

View File

@ -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<string, unknown>,
token: string
): Promise<unknown[]> {
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<string, unknown> = {};
// 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<string, unknown>[])[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<string, unknown>[]) || [])
.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<string, unknown>[])[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<string, unknown>[]) || [])
.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<string, unknown>) => (item.id as Record<string, string>)?.videoId)
.filter(Boolean)
.join(",");
// Step 3: Get video details — views, likes, duration (1 quota unit)
let videos: Record<string, unknown>[] = [];
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<string, string> || {};
const vSnippet = v.snippet as Record<string, unknown> || {};
const vContent = v.contentDetails as Record<string, string> || {};
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<string, Record<string, string>>)?.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" } }
);
}
});

View File

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

View File

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}

View File

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

View File

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