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": "병원명 (한국어)", "nameEn": "영문 병원명", "established": "개원년도 (예: 2005)", "address": "주소", "phone": "전화번호", "services": ["시술1", "시술2"], "doctors": [{"name": "의사명", "specialty": "전문분야"}], "socialMedia": { "instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"], "youtube": "YouTube 채널 핸들 또는 URL", "facebook": "Facebook 페이지명 또는 URL", "naverBlog": "네이버 블로그 ID" } }, "executiveSummary": "경영진 요약 (3-5문장)", "overallScore": 0-100, "channelAnalysis": { "naverBlog": { "score": 0-100, "status": "active|inactive|weak", "posts": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, "instagram": { "score": 0-100, "status": "active|inactive|weak", "followers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, "youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, "naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] }, "gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "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": "주요 전환 유도 요소", "diagnosis": [{"issue": "구체적 문제", "severity": "critical|warning|good", "recommendation": "개선 방안"}] } }, "newChannelProposals": [ { "channel": "제안 채널명", "priority": "P0|P1|P2", "rationale": "채널 개설 근거" } ], "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 구독자/조회수, 네이버 블로그 검색 노출 수, 강남언니 리뷰 수, Google Maps 평점 등). 병원 내부에서만 알 수 있는 지표(상담 문의, 매출, 예약 수 등)는 절대 포함하지 마세요.", "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" } } ); } });