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
parent
c6e18b6a67
commit
60cd055042
|
|
@ -100,8 +100,8 @@ background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff);
|
||||||
| 용도 | 폰트 | Size | Weight | Tailwind Class |
|
| 용도 | 폰트 | Size | Weight | Tailwind Class |
|
||||||
|------|-------|------|--------|---------------|
|
|------|-------|------|--------|---------------|
|
||||||
| INFINITH 로고 | Playfair Display | `3xl` (30px) | Black 900 | `font-serif text-3xl font-black tracking-[0.05em]` |
|
| 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` |
|
| 페이지 H1 | Playfair Display | `5xl~7xl` | Bold 700 | `font-serif text-5xl md:text-7xl font-bold tracking-[-0.02em]` |
|
||||||
| 섹션 타이틀 (영문) | Playfair Display | `3xl~4xl` | Bold 700 | `font-serif text-3xl md:text-4xl font-bold` |
|
| 섹션 타이틀 (영문) | 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) | Regular 400 | `text-lg` |
|
||||||
| 카드 제목 | Pretendard | `lg` (18px) | Bold 700 | `text-lg font-bold` |
|
| 카드 제목 | Pretendard | `lg` (18px) | Bold 700 | `text-lg font-bold` |
|
||||||
| 본문 | Pretendard / Inter | `sm~base` (14~16px) | Regular 400 | `text-sm` or `text-base` |
|
| 본문 | 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
|
## 7. PDF Export Rules
|
||||||
|
|
||||||
| 규칙 | 구현 |
|
| 규칙 | 구현 |
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.100.1",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
|
|
@ -1184,6 +1185,92 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||||
|
|
@ -1631,6 +1718,15 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
|
|
@ -2804,6 +2900,15 @@
|
||||||
"node": ">= 14"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.100.1",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,15 @@ function formatNumber(n: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoFields = (data: ClinicSnapshotType) => [
|
const infoFields = (data: ClinicSnapshotType) => [
|
||||||
{ label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar },
|
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
|
||||||
{ label: '의료진', value: `${data.staffCount}명`, icon: Users },
|
data.staffCount > 0 ? { label: '의료진', value: `${data.staffCount}명`, icon: Users } : null,
|
||||||
{ label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star },
|
data.overallRating > 0 ? { label: '강남언니 평점', value: `${data.overallRating} / 5.0`, icon: Star } : null,
|
||||||
{ label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star },
|
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
|
||||||
{ label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe },
|
data.priceRange.min !== '-' ? { label: '시술 가격대', value: `${data.priceRange.min} ~ ${data.priceRange.max}`, icon: Globe } : null,
|
||||||
{ label: '위치', value: `${data.location} (${data.nearestStation})`, icon: MapPin },
|
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
|
||||||
{ label: '전화', value: data.phone, icon: Phone },
|
data.phone ? { label: '전화', value: data.phone, icon: Phone } : null,
|
||||||
{ label: '도메인', value: data.domain, icon: Globe },
|
data.domain ? { label: '도메인', value: data.domain, icon: Globe } : null,
|
||||||
];
|
].filter((f): f is NonNullable<typeof f> => f !== null);
|
||||||
|
|
||||||
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
||||||
const fields = infoFields(data);
|
const fields = infoFields(data);
|
||||||
|
|
@ -55,38 +55,46 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lead Doctor Highlight */}
|
{/* Lead Doctor Highlight — only show if doctor name exists */}
|
||||||
<motion.div
|
{data.leadDoctor.name && (
|
||||||
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
viewport={{ once: true }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
viewport={{ once: true }}
|
||||||
>
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
>
|
||||||
<Award size={20} className="text-[#6C5CE7]" />
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<h3 className="font-serif font-bold text-2xl text-[#0A1128]">대표 원장</h3>
|
<Award size={20} className="text-[#6C5CE7]" />
|
||||||
</div>
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128]">대표 원장</h3>
|
||||||
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
|
|
||||||
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
size={16}
|
|
||||||
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span className="text-sm font-semibold text-[#0A1128] ml-1">
|
|
||||||
{data.leadDoctor.rating}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-slate-500">
|
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
|
||||||
리뷰 {formatNumber(data.leadDoctor.reviewCount)}건
|
{data.leadDoctor.credentials && (
|
||||||
</span>
|
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
|
||||||
</div>
|
)}
|
||||||
</motion.div>
|
{data.leadDoctor.rating > 0 && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
size={16}
|
||||||
|
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-sm font-semibold text-[#0A1128] ml-1">
|
||||||
|
{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 */}
|
{/* Certifications */}
|
||||||
{data.certifications.length > 0 && (
|
{data.certifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,10 @@ function ConsolidationCard({ text }: { text: string }) {
|
||||||
|
|
||||||
/* ─── Main Component ─── */
|
/* ─── Main Component ─── */
|
||||||
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
|
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
|
||||||
|
const hasData = data.pages.length > 0 || data.diagnosis.length > 0;
|
||||||
|
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
|
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
|
||||||
{/* Page cards side by side */}
|
{/* Page cards side by side */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Instagram, AlertCircle, FileText, Users, Eye } from 'lucide-react';
|
import { Instagram, AlertCircle, FileText, Users, Eye } from 'lucide-react';
|
||||||
import { SectionWrapper } from './ui/SectionWrapper';
|
import { SectionWrapper } from './ui/SectionWrapper';
|
||||||
|
import { EmptyState } from './ui/EmptyState';
|
||||||
import { MetricCard } from './ui/MetricCard';
|
import { MetricCard } from './ui/MetricCard';
|
||||||
import { DiagnosisRow } from './ui/DiagnosisRow';
|
import { DiagnosisRow } from './ui/DiagnosisRow';
|
||||||
import type { InstagramAudit as InstagramAuditType, InstagramAccount } from '../../types/report';
|
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) {
|
export default function InstagramAudit({ data }: InstagramAuditProps) {
|
||||||
|
const hasAccounts = data.accounts.length > 0 && data.accounts.some(a => a.handle || a.followers > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionWrapper id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
|
<SectionWrapper id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
|
||||||
|
{!hasAccounts && data.diagnosis.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
message="Instagram 계정 데이터 수집 중"
|
||||||
|
subtext="채널 데이터 보강이 완료되면 계정 정보와 분석 결과가 표시됩니다."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Account cards */}
|
{/* Account cards */}
|
||||||
|
{hasAccounts && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
{data.accounts.map((account, i) => (
|
{data.accounts.map((account, i) => (
|
||||||
<AccountCard key={account.handle} account={account} index={i} />
|
<AccountCard key={account.handle || i} account={account} index={i} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Diagnosis */}
|
{/* Diagnosis */}
|
||||||
{data.diagnosis.length > 0 && (
|
{data.diagnosis.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react';
|
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react';
|
||||||
import { SectionWrapper } from './ui/SectionWrapper';
|
import { SectionWrapper } from './ui/SectionWrapper';
|
||||||
|
import { EmptyState } from './ui/EmptyState';
|
||||||
import { MetricCard } from './ui/MetricCard';
|
import { MetricCard } from './ui/MetricCard';
|
||||||
import { DiagnosisRow } from './ui/DiagnosisRow';
|
import { DiagnosisRow } from './ui/DiagnosisRow';
|
||||||
import type { YouTubeAudit as YouTubeAuditType } from '../../types/report';
|
import type { YouTubeAudit as YouTubeAuditType } from '../../types/report';
|
||||||
|
|
@ -16,8 +17,18 @@ function formatNumber(n: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YouTubeAudit({ data }: YouTubeAuditProps) {
|
export default function YouTubeAudit({ data }: YouTubeAuditProps) {
|
||||||
|
const hasData = data.subscribers > 0 || data.totalVideos > 0 || data.topVideos.length > 0 || data.diagnosis.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
|
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
|
||||||
|
{!hasData && (
|
||||||
|
<EmptyState
|
||||||
|
message="YouTube 채널 데이터 수집 중"
|
||||||
|
subtext="채널 데이터 보강이 완료되면 구독자, 영상, 조회수 정보가 표시됩니다."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasData && <>
|
||||||
{/* Metrics row */}
|
{/* Metrics row */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
|
|
@ -172,6 +183,7 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</>}
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
import type { MarketingReport } from '../types/report';
|
import type { MarketingReport } from '../types/report';
|
||||||
import { mockReport } from '../data/mockReport';
|
import { fetchReportById } from '../lib/supabase';
|
||||||
|
import { transformApiReport } from '../lib/transformReport';
|
||||||
|
|
||||||
interface UseReportResult {
|
interface UseReportResult {
|
||||||
data: MarketingReport | null;
|
data: MarketingReport | null;
|
||||||
|
|
@ -8,27 +10,72 @@ interface UseReportResult {
|
||||||
error: string | null;
|
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 {
|
export function useReport(id: string | undefined): UseReportResult {
|
||||||
const [data, setData] = useState<MarketingReport | null>(null);
|
const [data, setData] = useState<MarketingReport | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
const state = location.state as LocationState | undefined;
|
||||||
setError('No report ID provided');
|
|
||||||
setIsLoading(false);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Return mock data immediately
|
// Source 2: Fetch from Supabase by report ID (bookmarked/shared link)
|
||||||
// Phase 2+: Replace with real API call — fetch(`/api/reports/${id}`)
|
if (id && id !== 'view-clinic') {
|
||||||
const timer = setTimeout(() => {
|
fetchReportById(id)
|
||||||
setData(mockReport);
|
.then((row) => {
|
||||||
setIsLoading(false);
|
const transformed = transformApiReport(
|
||||||
}, 100);
|
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);
|
// No data source available
|
||||||
}, [id]);
|
setError('리포트 데이터를 찾을 수 없습니다. 새 분석을 시작해주세요.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [id, location.state]);
|
||||||
|
|
||||||
return { data, isLoading, error };
|
return { data, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,82 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Check } from 'lucide-react';
|
import { Check, AlertCircle } from 'lucide-react';
|
||||||
|
import { generateMarketingReport } from '../lib/supabase';
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
'Scanning website...',
|
{ label: 'Scanning website...', key: 'scrape' },
|
||||||
'Capturing channel screenshots...',
|
{ label: 'Analyzing social media presence...', key: 'social' },
|
||||||
'Analyzing social media presence...',
|
{ label: 'Researching competitors & keywords...', key: 'analyze' },
|
||||||
'Evaluating brand consistency...',
|
{ label: 'Generating AI marketing report...', key: 'generate' },
|
||||||
'Generating intelligence report...',
|
{ label: 'Finalizing report...', key: 'finalize' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AnalysisLoadingPage() {
|
export default function AnalysisLoadingPage() {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const url = (location.state as { url?: string })?.url;
|
const url = (location.state as { url?: string })?.url;
|
||||||
|
const hasStarted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
if (hasStarted.current) return;
|
||||||
|
hasStarted.current = true;
|
||||||
|
|
||||||
steps.forEach((_, index) => {
|
if (!url) {
|
||||||
timers.push(
|
navigate('/', { replace: true });
|
||||||
setTimeout(() => {
|
return;
|
||||||
setCurrentStep(index + 1);
|
}
|
||||||
}, (index + 1) * 1250)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
timers.push(
|
const runAnalysis = async () => {
|
||||||
setTimeout(() => {
|
try {
|
||||||
navigate('/report/view-clinic', { replace: true });
|
// Step 1: Scraping
|
||||||
}, 5500)
|
setCurrentStep(1);
|
||||||
);
|
|
||||||
|
|
||||||
return () => timers.forEach(clearTimeout);
|
// Simulate step progression while waiting for API
|
||||||
}, [navigate]);
|
const stepTimer = setInterval(() => {
|
||||||
|
setCurrentStep((prev) => (prev < steps.length - 1 ? prev + 1 : prev));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const result = await generateMarketingReport(url);
|
||||||
|
|
||||||
|
clearInterval(stepTimer);
|
||||||
|
setCurrentStep(steps.length);
|
||||||
|
|
||||||
|
if (result.success && result.report) {
|
||||||
|
const reportPath = result.reportId
|
||||||
|
? `/report/${result.reportId}`
|
||||||
|
: '/report/live';
|
||||||
|
|
||||||
|
// Brief delay to show completion
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(reportPath, {
|
||||||
|
replace: true,
|
||||||
|
state: {
|
||||||
|
report: result.report,
|
||||||
|
metadata: result.metadata,
|
||||||
|
reportId: result.reportId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Report generation failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runAnalysis();
|
||||||
|
}, [url, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
|
<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%)]" />
|
<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="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">
|
<div className="relative z-10 flex flex-col items-center w-full max-w-lg">
|
||||||
{/* INFINITH logo text */}
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -56,7 +86,6 @@ export default function AnalysisLoadingPage() {
|
||||||
INFINITH
|
INFINITH
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Entered URL */}
|
|
||||||
{url && (
|
{url && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|
@ -68,64 +97,82 @@ export default function AnalysisLoadingPage() {
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Analysis steps */}
|
{error ? (
|
||||||
<div className="w-full space-y-5 mb-14">
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isCompleted = currentStep > index;
|
|
||||||
const isActive = currentStep === index;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={step}
|
|
||||||
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
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
||||||
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
|
||||||
</motion.div>
|
|
||||||
) : isActive ? (
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
|
||||||
) : (
|
|
||||||
<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
|
|
||||||
? 'text-white'
|
|
||||||
: isActive
|
|
||||||
? 'text-purple-200'
|
|
||||||
: 'text-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: '0%' }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ width: '100%' }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 5, ease: 'linear' }}
|
className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center"
|
||||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
>
|
||||||
/>
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||||
</div>
|
<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 + 1 && currentStep <= steps.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{isCompleted ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
) : isActive ? (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-base font-sans transition-colors duration-300 ${
|
||||||
|
isCompleted
|
||||||
|
? 'text-white'
|
||||||
|
: isActive
|
||||||
|
? 'text-purple-200'
|
||||||
|
: 'text-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { useReport } from '../hooks/useReport';
|
||||||
|
import { useEnrichment } from '../hooks/useEnrichment';
|
||||||
import { ReportNav } from '../components/report/ReportNav';
|
import { ReportNav } from '../components/report/ReportNav';
|
||||||
import { ScreenshotProvider } from '../contexts/ScreenshotContext';
|
import { ScreenshotProvider } from '../contexts/ScreenshotContext';
|
||||||
|
|
||||||
|
|
@ -25,14 +27,47 @@ const REPORT_SECTIONS = [
|
||||||
{ id: 'facebook-audit', label: 'Facebook' },
|
{ id: 'facebook-audit', label: 'Facebook' },
|
||||||
{ id: 'other-channels', label: '기타 채널' },
|
{ id: 'other-channels', label: '기타 채널' },
|
||||||
{ id: 'problem-diagnosis', label: '문제 진단' },
|
{ id: 'problem-diagnosis', label: '문제 진단' },
|
||||||
{ id: 'transformation', label: '변환 전략' },
|
|
||||||
{ id: 'roadmap', label: '로드맵' },
|
{ id: 'roadmap', label: '로드맵' },
|
||||||
{ id: 'kpi-dashboard', label: 'KPI' },
|
{ id: 'kpi-dashboard', label: 'KPI' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,6 +96,14 @@ export default function ReportPage() {
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav sections={REPORT_SECTIONS} />
|
<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>
|
<div data-report-content>
|
||||||
<ReportHeader
|
<ReportHeader
|
||||||
overallScore={data.overallScore}
|
overallScore={data.overallScore}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
@ -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" ]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue