251 lines
9.5 KiB
TypeScript
251 lines
9.5 KiB
TypeScript
import "@supabase/functions-js/edge-runtime.d.ts";
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
import { normalizeInstagramHandle } from "../_shared/normalizeHandles.ts";
|
|
|
|
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": "전문분야"}],
|
|
"socialMedia": {
|
|
"instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"],
|
|
"youtube": "YouTube 채널 핸들 또는 URL",
|
|
"facebook": "Facebook 페이지명",
|
|
"naverBlog": "네이버 블로그 ID"
|
|
}
|
|
},
|
|
"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": "추천사항", "trackingPixels": [{"name": "Google Analytics|Facebook Pixel|Naver Analytics|etc", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [{"domain": "example.com", "purpose": "용도"}], "mainCTA": "주요 전환 유도 요소" }
|
|
},
|
|
"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"] }
|
|
},
|
|
"brandIdentity": [
|
|
{ "area": "로고 및 비주얼", "asIs": "현재 로고/비주얼 아이덴티티 상태", "toBe": "개선 방향" },
|
|
{ "area": "브랜드 메시지/슬로건", "asIs": "현재 메시지", "toBe": "제안 메시지" },
|
|
{ "area": "톤앤보이스", "asIs": "현재 커뮤니케이션 스타일", "toBe": "권장 스타일" },
|
|
{ "area": "채널 일관성", "asIs": "채널별 불일치 사항", "toBe": "통일 방안" },
|
|
{ "area": "해시태그/키워드", "asIs": "현재 해시태그 전략", "toBe": "최적화된 해시태그 세트" },
|
|
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
|
|
],
|
|
"kpiTargets": [
|
|
{ "metric": "지표명 (종합 점수, Instagram 팔로워, YouTube 구독자, 네이버 블로그 방문자, 월간 상담 문의 등)", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
|
|
],
|
|
"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 };
|
|
}
|
|
|
|
// Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback)
|
|
const scrapeSocial = clinic.socialMedia || {};
|
|
const aiSocial = report?.clinicInfo?.socialMedia || {};
|
|
|
|
// Instagram: collect all accounts from AI + Firecrawl, deduplicate
|
|
const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts)
|
|
? aiSocial.instagramAccounts
|
|
: aiSocial.instagram ? [aiSocial.instagram] : [];
|
|
const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : [];
|
|
const allIgRaw = [...aiIgAccounts, ...scrapeIg];
|
|
const igHandles = [...new Set(
|
|
allIgRaw
|
|
.map((h: string) => normalizeInstagramHandle(h))
|
|
.filter((h): h is string => h !== null)
|
|
)];
|
|
|
|
// Filter out empty strings — AI sometimes returns "" instead of null
|
|
const pickNonEmpty = (...vals: (string | null | undefined)[]): string | null =>
|
|
vals.find(v => v && v.trim().length > 0) || null;
|
|
|
|
const normalizedHandles = {
|
|
instagram: igHandles.length > 0 ? igHandles : null,
|
|
youtube: pickNonEmpty(aiSocial.youtube, scrapeSocial.youtube),
|
|
facebook: pickNonEmpty(aiSocial.facebook, scrapeSocial.facebook),
|
|
blog: pickNonEmpty(aiSocial.naverBlog, scrapeSocial.blog),
|
|
};
|
|
|
|
// Embed normalized handles in report for DB persistence
|
|
report.socialHandles = normalizedHandles;
|
|
|
|
// 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();
|
|
|
|
if (saveError) {
|
|
console.error("DB save error:", saveError);
|
|
}
|
|
|
|
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: normalizedHandles,
|
|
saveError: saveError?.message || 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" } }
|
|
);
|
|
}
|
|
});
|