349 lines
17 KiB
TypeScript
349 lines
17 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";
|
|
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type",
|
|
};
|
|
|
|
interface ReportRequest {
|
|
// V2: reportId-based (Phase 3 — uses data already in DB)
|
|
reportId?: string;
|
|
// V1 compat: url-based (legacy single-call flow)
|
|
url?: string;
|
|
clinicName?: string;
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response("ok", { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const body = (await req.json()) as ReportRequest;
|
|
|
|
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")!;
|
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
|
|
// ─── V2 Pipeline: reportId provided (Phase 1 & 2 already ran) ───
|
|
if (body.reportId) {
|
|
const { data: row, error: fetchErr } = await supabase
|
|
.from("marketing_reports")
|
|
.select("*")
|
|
.eq("id", body.reportId)
|
|
.single();
|
|
if (fetchErr || !row) throw new Error(`Report not found: ${fetchErr?.message}`);
|
|
|
|
await supabase.from("marketing_reports").update({ status: "generating" }).eq("id", body.reportId);
|
|
|
|
const channelData = row.channel_data || {};
|
|
const analysisData = row.analysis_data || {};
|
|
const scrapeData = row.scrape_data || {};
|
|
const clinic = scrapeData.clinic || {};
|
|
const verified = row.verified_channels || {};
|
|
|
|
// Build real data summary for AI prompt
|
|
const channelSummary = buildChannelSummary(channelData, verified);
|
|
const marketSummary = JSON.stringify(analysisData.analysis || {}, null, 2).slice(0, 8000);
|
|
|
|
const reportPrompt = `
|
|
당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 **실제 수집된 데이터**를 기반으로 종합 마케팅 리포트를 생성해주세요.
|
|
|
|
⚠️ 중요: 아래 데이터에 없는 수치는 절대 추측하지 마세요. 데이터가 없으면 "데이터 없음"으로 표시하세요.
|
|
|
|
## 병원 기본 정보
|
|
- 병원명: ${clinic.clinicName || row.clinic_name}
|
|
- 주소: ${clinic.address || ""}
|
|
- 전화: ${clinic.phone || ""}
|
|
- 시술: ${(clinic.services || []).join(", ")}
|
|
- 의료진: ${JSON.stringify(clinic.doctors || []).slice(0, 500)}
|
|
- 슬로건: ${clinic.slogan || ""}
|
|
|
|
## 실제 채널 데이터 (수집 완료)
|
|
${channelSummary}
|
|
|
|
## 시장 분석 데이터
|
|
${marketSummary}
|
|
|
|
## 웹사이트 브랜딩
|
|
${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
|
|
|
## 리포트 형식 (반드시 아래 JSON 구조로 응답)
|
|
{
|
|
"clinicInfo": {
|
|
"name": "병원명 (한국어)",
|
|
"nameEn": "영문 병원명",
|
|
"established": "개원년도",
|
|
"address": "주소",
|
|
"phone": "전화번호",
|
|
"services": ["시술1", "시술2"],
|
|
"doctors": [{"name": "의사명", "specialty": "전문분야"}]
|
|
},
|
|
"executiveSummary": "경영진 요약 (3-5문장)",
|
|
"overallScore": 0-100,
|
|
"channelAnalysis": {
|
|
"naverBlog": { "score": 0-100, "status": "active|inactive|not_found", "posts": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "문제", "severity": "critical|warning|good", "recommendation": "개선안"}] },
|
|
"instagram": { "score": 0-100, "status": "active|inactive|not_found", "followers": 실제수치, "posts": 실제수치, "recommendation": "추천사항", "diagnosis": [{"issue": "문제", "severity": "critical|warning|good", "recommendation": "개선안"}] },
|
|
"youtube": { "score": 0-100, "status": "active|inactive|not_found", "subscribers": 실제수치, "recommendation": "추천사항", "diagnosis": [{"issue": "문제", "severity": "critical|warning|good", "recommendation": "개선안"}] },
|
|
"naverPlace": { "score": 0-100, "rating": 실제수치, "reviews": 실제수치, "recommendation": "추천사항" },
|
|
"gangnamUnni": { "score": 0-100, "rating": 실제수치, "ratingScale": 10, "reviews": 실제수치, "status": "active|not_found", "recommendation": "추천사항" },
|
|
"website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "이름", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [], "mainCTA": "주요 CTA" }
|
|
},
|
|
"newChannelProposals": [{ "channel": "채널명", "priority": "P0|P1|P2", "rationale": "근거" }],
|
|
"competitors": [{ "name": "경쟁병원", "strengths": [], "weaknesses": [], "marketingChannels": [] }],
|
|
"keywords": { "primary": [{"keyword": "키워드", "monthlySearches": 0, "competition": "high|medium|low"}], "longTail": [{"keyword": "키워드"}] },
|
|
"targetAudience": { "primary": { "ageRange": "", "gender": "", "interests": [], "channels": [] } },
|
|
"brandIdentity": [{ "area": "영역", "asIs": "현재", "toBe": "개선" }],
|
|
"kpiTargets": [{ "metric": "지표명 (실제 수집된 수치 기반으로 현실적 목표 설정)", "current": "현재 실제 수치", "target3Month": "3개월 목표", "target12Month": "12개월 목표" }],
|
|
"recommendations": [{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "효과" }],
|
|
"marketTrends": ["트렌드1"]
|
|
}
|
|
`;
|
|
|
|
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: PERPLEXITY_MODEL,
|
|
messages: [
|
|
{ role: "system", content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Use Korean for text fields. 강남언니 rating is 10-point scale. Use ONLY the provided real data — never invent metrics." },
|
|
{ role: "user", content: reportPrompt },
|
|
],
|
|
temperature: 0.3,
|
|
}),
|
|
});
|
|
|
|
const aiData = await aiRes.json();
|
|
let reportText = aiData.choices?.[0]?.message?.content || "";
|
|
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 }; }
|
|
|
|
// Embed channel enrichment data for frontend mergeEnrichment()
|
|
report.channelEnrichment = channelData;
|
|
report.enrichedAt = new Date().toISOString();
|
|
|
|
// Embed verified handles
|
|
const igHandles = (verified.instagram || []).filter((v: { verified: boolean }) => v.verified).map((v: { handle: string }) => v.handle);
|
|
report.socialHandles = {
|
|
instagram: igHandles.length > 0 ? igHandles : null,
|
|
youtube: verified.youtube?.verified ? verified.youtube.handle : null,
|
|
facebook: verified.facebook?.verified ? verified.facebook.handle : null,
|
|
};
|
|
|
|
await supabase.from("marketing_reports").update({
|
|
report,
|
|
status: "complete",
|
|
pipeline_completed_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
}).eq("id", body.reportId);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
reportId: body.reportId,
|
|
report,
|
|
metadata: {
|
|
url: row.url,
|
|
clinicName: row.clinic_name,
|
|
generatedAt: new Date().toISOString(),
|
|
dataSources: { scraping: true, marketAnalysis: true, aiGeneration: !report.parseError },
|
|
socialHandles: report.socialHandles,
|
|
address: clinic.address || "",
|
|
services: clinic.services || [],
|
|
},
|
|
}),
|
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
// ─── V1 Legacy: url-based single-call flow (backwards compat) ───
|
|
const { url, clinicName } = body;
|
|
if (!url) {
|
|
return new Response(JSON.stringify({ error: "URL or reportId is required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
// Call scrape-website
|
|
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 || "";
|
|
|
|
// Call analyze-market
|
|
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();
|
|
|
|
// Generate report with Perplexity (legacy prompt)
|
|
const reportPrompt = `당신은 프리미엄 의료 마케팅 전문 분석가입니다. 아래 데이터를 기반으로 종합 마케팅 인텔리전스 리포트를 생성해주세요.
|
|
|
|
## 수집된 데이터
|
|
### 병원 정보
|
|
${JSON.stringify(scrapeResult.data, null, 2).slice(0, 6000)}
|
|
|
|
### 시장 분석
|
|
${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2).slice(0, 4000)}
|
|
|
|
## 리포트 형식 (반드시 아래 JSON 구조로 응답)
|
|
{
|
|
"clinicInfo": { "name": "병원명", "nameEn": "영문명", "established": "개원년도", "address": "주소", "phone": "전화", "services": [], "doctors": [], "socialMedia": { "instagramAccounts": [], "youtube": "", "facebook": "", "naverBlog": "" } },
|
|
"executiveSummary": "요약", "overallScore": 0-100,
|
|
"channelAnalysis": { "naverBlog": { "score": 0-100, "status": "active|inactive", "recommendation": "" }, "instagram": { "score": 0-100, "followers": 0, "recommendation": "" }, "youtube": { "score": 0-100, "subscribers": 0, "recommendation": "" }, "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0 }, "gangnamUnni": { "score": 0-100, "rating": 0, "ratingScale": 10, "reviews": 0, "status": "active|not_found" }, "website": { "score": 0-100, "issues": [], "trackingPixels": [], "snsLinksOnSite": false, "mainCTA": "" } },
|
|
"brandIdentity": [{ "area": "", "asIs": "", "toBe": "" }],
|
|
"kpiTargets": [{ "metric": "", "current": "", "target3Month": "", "target12Month": "" }],
|
|
"recommendations": [{ "priority": "high|medium|low", "category": "", "title": "", "description": "", "expectedImpact": "" }],
|
|
"newChannelProposals": [{ "channel": "", "priority": "P0|P1|P2", "rationale": "" }],
|
|
"marketTrends": []
|
|
}`;
|
|
|
|
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: PERPLEXITY_MODEL,
|
|
messages: [
|
|
{ role: "system", content: "You are a Korean medical marketing analyst. Respond ONLY with valid JSON, no markdown code blocks. Korean for text fields. 강남언니 rating uses 10-point scale." },
|
|
{ role: "user", content: reportPrompt },
|
|
],
|
|
temperature: 0.3,
|
|
}),
|
|
});
|
|
|
|
const aiData = await aiRes.json();
|
|
let reportText = aiData.choices?.[0]?.message?.content || "";
|
|
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
|
|
const scrapeSocial = clinic.socialMedia || {};
|
|
const aiSocial = report?.clinicInfo?.socialMedia || {};
|
|
const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts) ? aiSocial.instagramAccounts : aiSocial.instagram ? [aiSocial.instagram] : [];
|
|
const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : [];
|
|
const igHandles = [...new Set([...aiIgAccounts, ...scrapeIg].map((h: string) => normalizeInstagramHandle(h)).filter((h): h is string => h !== 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),
|
|
};
|
|
report.socialHandles = normalizedHandles;
|
|
|
|
// Save
|
|
const { data: saved, error: saveError } = await supabase.from("marketing_reports").insert({
|
|
url, clinic_name: resolvedName, report, scrape_data: scrapeResult.data, analysis_data: analyzeResult.data, status: "complete",
|
|
}).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" } },
|
|
);
|
|
}
|
|
});
|
|
|
|
// ─── Helper: Build channel summary from collected data ───
|
|
|
|
function buildChannelSummary(channelData: Record<string, unknown>, verified: Record<string, unknown>): string {
|
|
const parts: string[] = [];
|
|
|
|
// Instagram
|
|
const igAccounts = channelData.instagramAccounts as Record<string, unknown>[] | undefined;
|
|
if (igAccounts?.length) {
|
|
for (const ig of igAccounts) {
|
|
parts.push(`### Instagram @${ig.username}`);
|
|
parts.push(`- 팔로워: ${(ig.followers as number || 0).toLocaleString()}명, 게시물: ${ig.posts}개`);
|
|
parts.push(`- 비즈니스 계정: ${ig.isBusinessAccount ? 'O' : 'X'}`);
|
|
parts.push(`- Bio: ${(ig.bio as string || '').slice(0, 200)}`);
|
|
}
|
|
} else {
|
|
parts.push("### Instagram: 데이터 없음");
|
|
}
|
|
|
|
// YouTube
|
|
const yt = channelData.youtube as Record<string, unknown> | undefined;
|
|
if (yt) {
|
|
parts.push(`### YouTube ${yt.handle || yt.channelName}`);
|
|
parts.push(`- 구독자: ${(yt.subscribers as number || 0).toLocaleString()}명, 영상: ${yt.totalVideos}개, 총 조회수: ${(yt.totalViews as number || 0).toLocaleString()}`);
|
|
parts.push(`- 채널 설명: ${(yt.description as string || '').slice(0, 300)}`);
|
|
const videos = yt.videos as Record<string, unknown>[] | undefined;
|
|
if (videos?.length) {
|
|
parts.push(`- 인기 영상 TOP ${videos.length}:`);
|
|
for (const v of videos.slice(0, 5)) {
|
|
parts.push(` - "${v.title}" (조회수: ${(v.views as number || 0).toLocaleString()}, 좋아요: ${v.likes})`);
|
|
}
|
|
}
|
|
} else {
|
|
parts.push("### YouTube: 데이터 없음");
|
|
}
|
|
|
|
// Facebook
|
|
const fb = channelData.facebook as Record<string, unknown> | undefined;
|
|
if (fb) {
|
|
parts.push(`### Facebook ${fb.pageName}`);
|
|
parts.push(`- 팔로워: ${(fb.followers as number || 0).toLocaleString()}, 좋아요: ${fb.likes}`);
|
|
parts.push(`- 소개: ${(fb.intro as string || '').slice(0, 200)}`);
|
|
}
|
|
|
|
// 강남언니
|
|
const gu = channelData.gangnamUnni as Record<string, unknown> | undefined;
|
|
if (gu) {
|
|
parts.push(`### 강남언니 ${gu.name}`);
|
|
parts.push(`- 평점: ${gu.rating}/10, 리뷰: ${(gu.totalReviews as number || 0).toLocaleString()}건`);
|
|
const doctors = gu.doctors as Record<string, unknown>[] | undefined;
|
|
if (doctors?.length) {
|
|
parts.push(`- 등록 의사: ${doctors.map(d => `${d.name}(${d.specialty})`).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Google Maps
|
|
const gm = channelData.googleMaps as Record<string, unknown> | undefined;
|
|
if (gm) {
|
|
parts.push(`### Google Maps ${gm.name}`);
|
|
parts.push(`- 평점: ${gm.rating}/5, 리뷰: ${gm.reviewCount}건`);
|
|
}
|
|
|
|
// Naver
|
|
const nb = channelData.naverBlog as Record<string, unknown> | undefined;
|
|
if (nb) {
|
|
parts.push(`### 네이버 블로그: 검색결과 ${nb.totalResults}건`);
|
|
}
|
|
const np = channelData.naverPlace as Record<string, unknown> | undefined;
|
|
if (np) {
|
|
parts.push(`### 네이버 플레이스: ${np.name} (${np.category})`);
|
|
}
|
|
|
|
return parts.join("\n");
|
|
}
|