o2o-infinith-demo/supabase/functions/generate-report/index.ts

211 lines
7.0 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": "전문분야"}]
},
"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 };
}
// Normalize social handles from scrape data
const socialMedia = clinic.socialMedia || {};
const normalizedHandles = {
instagram: normalizeInstagramHandle(socialMedia.instagram),
youtube: socialMedia.youtube || null,
facebook: socialMedia.facebook || null,
blog: socialMedia.blog || null,
};
// 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();
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,
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" } }
);
}
});