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"; import { isMissingValue, validateReportQuality } from "../_shared/dataQuality.ts"; import { extractFoundingYear } from "../_shared/foundingYearExtractor.ts"; import { fetchWithRetry } from "../_shared/retry.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; // V3: clinic + run IDs for new schema clinicId?: string; runId?: 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"] } `; // Use fetchWithRetry to handle transient Perplexity failures (429, 502, 503). // 2 retries with 5s/15s backoff — total budget ~3min before giving up. const aiRes = await fetchWithRetry("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, }), }, { maxRetries: 2, backoffMs: [5000, 15000], timeoutMs: 90000, label: "perplexity-report" }); 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 }; } // ─── Post-processing: Inject Vision Analysis data directly ─── // Perplexity may ignore Vision data in prompt, so we force-inject critical fields const vision = channelData.visionAnalysis as Record | undefined; // Use Harness 3's isMissingValue() — covers all known LLM "no data" variants if (vision) { // Force-inject foundingYear if Vision found it but Perplexity didn't if (vision.foundingYear && isMissingValue(report.clinicInfo?.established)) { report.clinicInfo = report.clinicInfo || {}; report.clinicInfo.established = String(vision.foundingYear); console.log(`[report] Injected foundingYear from Vision: ${vision.foundingYear}`); } if (vision.operationYears && isMissingValue(report.clinicInfo?.established)) { const year = new Date().getFullYear() - Number(vision.operationYears); report.clinicInfo = report.clinicInfo || {}; report.clinicInfo.established = String(year); console.log(`[report] Calculated foundingYear from operationYears: ${vision.operationYears} → ${year}`); } // Force-inject doctors from Vision if report has none const visionDoctors = vision.doctors as { name: string; specialty: string; position?: string }[] | undefined; if (visionDoctors?.length && (!report.clinicInfo?.doctors?.length)) { report.clinicInfo = report.clinicInfo || {}; report.clinicInfo.doctors = visionDoctors; console.log(`[report] Injected ${visionDoctors.length} doctors from Vision`); } // Force-inject 강남언니 data from Vision if channelAnalysis has none/zero const guVision = vision.gangnamUnniStats as Record | undefined; if (guVision) { report.channelAnalysis = report.channelAnalysis || {}; const existing = report.channelAnalysis.gangnamUnni; if (!existing || existing.score === 0 || !existing.rating) { const rating = parseFloat(String(guVision.rating || 0)); const reviewStr = String(guVision.reviews || "0").replace(/[^0-9]/g, ""); const reviews = parseInt(reviewStr) || 0; // Score: rating 10점 만점 기준 + 리뷰 수 보정 const ratingScore = Math.min(rating * 10, 100); const reviewBonus = Math.min(reviews / 1000, 10); // 1000리뷰당 +1, max +10 const score = Math.round(Math.min(ratingScore + reviewBonus, 100)); report.channelAnalysis.gangnamUnni = { ...(existing || {}), score, rating, ratingScale: 10, reviews, doctors: Number(guVision.doctors) || 0, status: "active", recommendation: `평점 ${guVision.rating}/10, 리뷰 ${guVision.reviews}건 — 강남언니에서 활발한 활동`, }; console.log(`[report] Injected gangnamUnni from Vision: score=${score}, rating=${rating}, reviews=${reviews}`); } } // Store Vision analysis separately for frontend report.visionAnalysis = vision; } // Embed channel enrichment data for frontend mergeEnrichment() report.channelEnrichment = channelData; report.enrichedAt = new Date().toISOString(); // Embed screenshots as evidence for frontend EvidenceGallery const screenshots = (channelData.screenshots || []) as Record[]; if (screenshots.length > 0) { report.screenshots = screenshots.map((ss: Record) => ({ id: ss.id, url: ss.url, // data URI or Storage URL channel: ss.channel, capturedAt: ss.capturedAt, caption: ss.caption, sourceUrl: ss.sourceUrl, })); // 채널별 스크린샷 그룹핑 (프론트엔드에서 각 채널 섹션에 증거 이미지 표시) report.channelScreenshots = screenshots.reduce((acc: Record, ss: Record) => { const channel = ss.channel as string || 'etc'; if (!acc[channel]) acc[channel] = []; acc[channel].push({ url: ss.url, caption: ss.caption, id: ss.id, sourceUrl: ss.sourceUrl }); return acc; }, {} as Record); } // Instagram Posts/Reels 분석 (engagement rate, 콘텐츠 성과) const igProfile = channelData.instagram as Record | undefined; const igPosts = channelData.instagramPosts as Record | undefined; const igReels = channelData.instagramReels as Record | undefined; if (igProfile && (igPosts || igReels)) { const followers = (igProfile.followers as number) || 0; const avgLikes = (igPosts?.avgLikes as number) || 0; const avgComments = (igPosts?.avgComments as number) || 0; // 해시태그 빈도 분석 const hashtagCounts: Record = {}; const allPosts = [...((igPosts?.posts as Record[]) || []), ...((igReels?.reels as Record[]) || [])]; for (const post of allPosts) { for (const tag of (post.hashtags as string[]) || []) { const lower = tag.toLowerCase(); hashtagCounts[lower] = (hashtagCounts[lower] || 0) + 1; } } const topHashtags = Object.entries(hashtagCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([tag, count]) => ({ tag, count })); // 게시 빈도 (최근 포스트 간 평균 간격) const postTimestamps = ((igPosts?.posts as Record[]) || []) .map(p => new Date(p.timestamp as string).getTime()) .filter(t => !isNaN(t)) .sort((a, b) => b - a); let postFrequency = 'N/A'; if (postTimestamps.length >= 2) { const spanDays = (postTimestamps[0] - postTimestamps[postTimestamps.length - 1]) / (1000 * 60 * 60 * 24); const postsPerWeek = spanDays > 0 ? (postTimestamps.length / spanDays * 7).toFixed(1) : 'N/A'; postFrequency = `${postsPerWeek}회/주`; } // 최고 성과 포스트 const bestPost = ((igPosts?.posts as Record[]) || []) .sort((a, b) => ((b.likesCount as number) || 0) - ((a.likesCount as number) || 0))[0] || null; report.instagramAnalysis = { engagementRate: followers > 0 ? ((avgLikes + avgComments) / followers * 100).toFixed(2) + '%' : 'N/A', avgLikes, avgComments, topHashtags, postFrequency, bestPerformingPost: bestPost ? { url: bestPost.url, likes: bestPost.likesCount, comments: bestPost.commentsCount, caption: ((bestPost.caption as string) || '').slice(0, 200), } : null, reelsPerformance: igReels ? { totalReels: igReels.totalReels, avgViews: igReels.avgViews, avgPlays: igReels.avgPlays, viewToFollowerRatio: followers > 0 ? (((igReels.avgViews as number) || 0) / followers * 100).toFixed(1) + '%' : 'N/A', } : null, totalPostsAnalyzed: (igPosts?.totalPosts as number) || 0, totalReelsAnalyzed: (igReels?.totalReels as number) || 0, }; } // 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, }; // ─── Harness 2 (tertiary): Last-resort founding year from all channel text ─── if (isMissingValue(report.clinicInfo?.established)) { const allTexts = [ channelData.scrapeMarkdown, channelData.naverBlog?.description, channelData.gangnamUnni?.description, JSON.stringify(channelData.visionPerPage || {}), ].filter(Boolean).join(" "); const lastResortYear = extractFoundingYear(allTexts); if (lastResortYear) { report.clinicInfo = report.clinicInfo || {}; report.clinicInfo.established = String(lastResortYear); console.log(`[harness] Founding year from tertiary text scan: ${lastResortYear}`); } } // ─── Harness 3: Report quality validation ─── const qualityReport = validateReportQuality(report); report.dataQualityScore = qualityReport.score; report.dataQualityDetails = { missingCritical: qualityReport.missingCritical, missingImportant: qualityReport.missingImportant, warnings: qualityReport.warnings, }; if (qualityReport.score < 60) { console.warn(`[harness] Low report quality (${qualityReport.score}/100):`, qualityReport.warnings); } // Legacy: marketing_reports await supabase.from("marketing_reports").update({ report, status: "complete", data_quality_score: qualityReport.score, pipeline_completed_at: new Date().toISOString(), updated_at: new Date().toISOString(), }).eq("id", body.reportId); // V3: analysis_runs + clinics const v3RunId = body.runId || null; const v3ClinicId = body.clinicId || null; if (v3RunId) { try { await supabase.from("analysis_runs").update({ report, status: "complete", pipeline_completed_at: new Date().toISOString(), }).eq("id", v3RunId); } catch (e) { const errMsg = e instanceof Error ? e.message : String(e); console.error("V3 run update error:", errMsg); try { await supabase.from("analysis_runs").update({ error_message: `V3 report update failed: ${errMsg}`, status: "report_error", }).eq("id", v3RunId); } catch { /* ignore secondary failure */ } } } if (v3ClinicId) { try { await supabase.from("clinics").update({ last_analyzed_at: new Date().toISOString(), established_year: report.clinicInfo?.established ? parseInt(report.clinicInfo.established) || null : null, updated_at: new Date().toISOString(), }).eq("id", v3ClinicId); } catch (e) { console.error("V3 clinic update error:", e); } } // ─── Storage: save report.json to clinics/{domain}/{reportId}/ ─── try { const domain = new URL(row.url || "").hostname.replace('www.', ''); const jsonBytes = new TextEncoder().encode(JSON.stringify(report, null, 2)); await supabase.storage .from('clinic-data') .upload(`clinics/${domain}/${body.reportId}/report.json`, jsonBytes, { contentType: 'application/json', upsert: true, }); console.log(`[storage] report.json → clinics/${domain}/${body.reportId}/`); } catch (e) { console.warn('[storage] report.json upload failed:', e instanceof Error ? e.message : e); } 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 || [], // Registry metadata — available when discovered via clinic_registry DB source: scrapeData.source || "scrape", registryData: scrapeData.registryData || null, }, }), { 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, verified: Record): string { const parts: string[] = []; // Instagram const igAccounts = channelData.instagramAccounts as Record[] | 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 | 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[] | 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 | 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 | undefined; if (gu) { parts.push(`### 강남언니 ${gu.name}`); parts.push(`- 평점: ${gu.rating}/10, 리뷰: ${(gu.totalReviews as number || 0).toLocaleString()}건`); const doctors = gu.doctors as Record[] | undefined; if (doctors?.length) { parts.push(`- 등록 의사: ${doctors.map(d => `${d.name}(${d.specialty})`).join(', ')}`); } } // Google Maps const gm = channelData.googleMaps as Record | undefined; if (gm) { parts.push(`### Google Maps ${gm.name}`); parts.push(`- 평점: ${gm.rating}/5, 리뷰: ${gm.reviewCount}건`); } // Naver const nb = channelData.naverBlog as Record | undefined; if (nb) { parts.push(`### 네이버 블로그: 검색결과 ${nb.totalResults}건`); } const np = channelData.naverPlace as Record | undefined; if (np) { parts.push(`### 네이버 플레이스: ${np.name} (${np.category})`); } // Vision Analysis (from Gemini Vision on screenshots) const vision = channelData.visionAnalysis as Record | undefined; if (vision) { parts.push("\n### Vision Analysis (스크린샷 기반 추출 데이터)"); if (vision.foundingYear) parts.push(`- 개원 연도: ${vision.foundingYear}`); if (vision.operationYears) parts.push(`- 운영 기간: ${vision.operationYears}년`); const doctors = vision.doctors as { name: string; specialty: string; position?: string }[] | undefined; if (doctors?.length) { parts.push(`- 의료진 (스크린샷 확인): ${doctors.map(d => `${d.name}(${d.specialty}${d.position ? ', ' + d.position : ''})`).join(', ')}`); } const certs = vision.certifications as string[] | undefined; if (certs?.length) parts.push(`- 인증: ${certs.join(', ')}`); const services = vision.serviceCategories as string[] | undefined; if (services?.length) parts.push(`- 시술 카테고리: ${services.join(', ')}`); const slogans = vision.slogans as string[] | undefined; if (slogans?.length) parts.push(`- 슬로건: ${slogans.join(' / ')}`); const ytStats = vision.youtubeStats as Record | undefined; if (ytStats) parts.push(`- YouTube (스크린샷): 구독자 ${ytStats.subscribers || '?'}, 영상 ${ytStats.videos || '?'}`); const igStats = vision.instagramStats as Record | undefined; if (igStats) parts.push(`- Instagram (스크린샷): 팔로워 ${igStats.followers || '?'}, 게시물 ${igStats.posts || '?'}`); const guStats = vision.gangnamUnniStats as Record | undefined; if (guStats) parts.push(`- 강남언니 (스크린샷): 평점 ${guStats.rating || '?'}/10, 리뷰 ${guStats.reviews || '?'}건, 의사 ${guStats.doctors || '?'}명`); } return parts.join("\n"); }