diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts index 83bad93..edf6d4c 100644 --- a/src/lib/transformReport.ts +++ b/src/lib/transformReport.ts @@ -994,8 +994,10 @@ export function mergeEnrichment( name: '구글 지도', status: 'active' as const, details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`, - // Always use Google Maps search URL — gm.website is the clinic's own site, not Maps - url: gm.name ? `https://www.google.com/maps/search/${encodeURIComponent(String(gm.name))}` : '', + // Use Maps URL from enrichment if available, fallback to search URL + url: (gm as Record).mapsUrl + ? String((gm as Record).mapsUrl) + : gm.name ? `https://www.google.com/maps/search/${encodeURIComponent(String(gm.name))}` : '', }; if (gmChannelIdx >= 0) { merged.otherChannels[gmChannelIdx] = gmChannel; @@ -1090,8 +1092,10 @@ export function mergeEnrichment( name: '네이버 블로그', status: 'active' as const, details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}개`, - // Always link to Naver blog search — individual post links may be unrelated personal blogs - url: nb.searchQuery ? `https://search.naver.com/search.naver?where=blog&query=${encodeURIComponent(String(nb.searchQuery))}` : '', + // Prefer official blog URL from Phase 1, fallback to search URL + url: (nb as Record).officialBlogUrl + ? String((nb as Record).officialBlogUrl) + : nb.searchQuery ? `https://search.naver.com/search.naver?where=blog&query=${encodeURIComponent(String(nb.searchQuery))}` : '', }; if (nbChannelIdx >= 0) { merged.otherChannels[nbChannelIdx] = nbChannel; diff --git a/supabase/functions/_shared/config.ts b/supabase/functions/_shared/config.ts new file mode 100644 index 0000000..3982032 --- /dev/null +++ b/supabase/functions/_shared/config.ts @@ -0,0 +1,6 @@ +/** + * Shared configuration constants for Edge Functions. + * Centralizes API model names and defaults to prevent hardcoding. + */ + +export const PERPLEXITY_MODEL = Deno.env.get("PERPLEXITY_MODEL") || "sonar"; diff --git a/supabase/functions/_shared/verifyHandles.ts b/supabase/functions/_shared/verifyHandles.ts index 7812189..1f896b6 100644 --- a/supabase/functions/_shared/verifyHandles.ts +++ b/supabase/functions/_shared/verifyHandles.ts @@ -66,7 +66,7 @@ async function verifyYouTube(handle: string, apiKey: string): Promise { Authorization: `Bearer ${PERPLEXITY_API_KEY}`, }, body: JSON.stringify({ - model: "sonar", + model: PERPLEXITY_MODEL, messages: [ { role: "system", diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts index 33caf1b..e957cb5 100644 --- a/supabase/functions/collect-channel-data/index.ts +++ b/supabase/functions/collect-channel-data/index.ts @@ -1,6 +1,7 @@ import "@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import type { VerifiedChannels } from "../_shared/verifyHandles.ts"; +import { PERPLEXITY_MODEL } from "../_shared/config.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -200,7 +201,7 @@ Deno.serve(async (req) => { url: guVerified!.url as string, formats: ["json"], jsonOptions: { - prompt: "Extract: hospital name, overall rating (out of 10), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges", + prompt: "Extract: hospital name, overall rating (강남언니 rating is always out of 10, NOT out of 5), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges", schema: { type: "object", properties: { @@ -218,7 +219,10 @@ Deno.serve(async (req) => { const hospital = data.data?.json; if (hospital?.hospitalName) { channelData.gangnamUnni = { - name: hospital.hospitalName, rating: hospital.rating, ratingScale: "/10", + name: hospital.hospitalName, + rawRating: hospital.rating, + rating: typeof hospital.rating === 'number' && hospital.rating > 0 && hospital.rating <= 5 ? hospital.rating * 2 : hospital.rating, + ratingScale: "/10", totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10), procedures: hospital.procedures || [], address: hospital.address, badges: hospital.badges || [], sourceUrl: guVerified!.url as string, @@ -232,12 +236,20 @@ Deno.serve(async (req) => { const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET }; tasks.push((async () => { + // Get verified Naver Blog handle from Phase 1 for official blog URL + const nbVerified = verified.naverBlog as Record | null; + const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null; + const query = encodeURIComponent(`${clinicName} 후기`); const res = await fetch(`https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, { headers: naverHeaders }); if (!res.ok) return; const data = await res.json(); channelData.naverBlog = { totalResults: data.total || 0, searchQuery: `${clinicName} 후기`, + // Official blog URL from Phase 1 verified handle + officialBlogUrl: officialBlogHandle ? `https://blog.naver.com/${officialBlogHandle}` : null, + officialBlogHandle: officialBlogHandle, + // Blog mentions (third-party posts, NOT the official blog) posts: (data.items || []).slice(0, 10).map((item: Record) => ({ title: (item.title || "").replace(/<[^>]*>/g, ""), description: (item.description || "").replace(/<[^>]*>/g, ""), @@ -294,7 +306,9 @@ Deno.serve(async (req) => { if (place) { channelData.googleMaps = { name: place.title, rating: place.totalScore, reviewCount: place.reviewsCount, - address: place.address, phone: place.phone, website: place.website, + address: place.address, phone: place.phone, + clinicWebsite: place.website, // clinic's own website (not Maps URL) + mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''), category: place.categoryName, openingHours: place.openingHours, topReviews: ((place.reviews as Record[]) || []).slice(0, 10).map(r => ({ stars: r.stars, text: r.text, publishedAtDate: r.publishedAtDate, @@ -319,7 +333,7 @@ Deno.serve(async (req) => { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", messages: [ + model: PERPLEXITY_MODEL, messages: [ { role: "system", content: "You are a Korean medical marketing analyst. Always respond in Korean. Provide data in valid JSON format." }, { role: "user", content: q.prompt }, ], temperature: 0.3, diff --git a/supabase/functions/discover-channels/index.ts b/supabase/functions/discover-channels/index.ts index 8ce49d8..4e2a146 100644 --- a/supabase/functions/discover-channels/index.ts +++ b/supabase/functions/discover-channels/index.ts @@ -1,6 +1,7 @@ import "@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { extractSocialLinks, mergeSocialLinks } from "../_shared/extractSocialLinks.ts"; +import { PERPLEXITY_MODEL } from "../_shared/config.ts"; import { verifyAllHandles, type VerifiedChannels } from "../_shared/verifyHandles.ts"; import { RESEARCH_SYSTEM_PROMPT, buildResearchUserPrompt } from "../_shared/researchPrompt.ts"; @@ -31,7 +32,7 @@ function extractHandle(raw: string, platform: string): string | null { if (m) return m[1] ? `@${m[1]}` : m[2] || m[3] || null; h = h.replace(/^@/, ''); if (h.includes('http') || h.includes('/') || h.includes('.com')) return null; - if (/^UC[a-zA-Z0-9_-]{20,}$/.test(h)) return h; + if (/^UC[a-zA-Z0-9_-]{22}$/.test(h)) return h; // YouTube channel IDs are exactly 24 chars (UC + 22) if (/^[a-zA-Z0-9._-]+$/.test(h) && h.length >= 2) return `@${h}`; return null; } @@ -152,7 +153,7 @@ Deno.serve(async (req) => { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", + model: PERPLEXITY_MODEL, messages: [ { role: "system", content: "Respond with ONLY the clinic name in Korean, nothing else." }, { role: "user", content: `${url} 이 URL의 병원/클리닉 한국어 이름이 뭐야?` }, @@ -300,7 +301,7 @@ Deno.serve(async (req) => { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", + model: PERPLEXITY_MODEL, messages: [ { role: "system", content: "You are a social media researcher. Search the web and find social media accounts. Respond ONLY with valid JSON." }, { role: "user", content: `${searchName} 병원의 인스타그램, 유튜브, 페이스북, 틱톡, 네이버블로그 계정을 검색해서 찾아줘. 검색 결과에서 발견된 계정을 모두 알려줘. 인스타그램은 여러 계정이 있을 수 있어.\n\n{"instagram": ["handle1", "handle2"], "youtube": "channel URL or handle", "facebook": "page name or URL", "tiktok": "handle", "naverBlog": "blog ID"}` }, @@ -333,7 +334,7 @@ Deno.serve(async (req) => { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", + model: PERPLEXITY_MODEL, messages: [ { role: "system", content: "You search for clinic listings on medical platforms. Respond ONLY with valid JSON." }, { role: "user", content: `${resolvedName} 병원 강남언니 gangnamunni.com 페이지를 찾아줘.\n\n{"gangnamUnni": {"url": "https://gangnamunni.com/hospitals/...", "rating": 9.5, "reviews": 1000}}` }, @@ -377,7 +378,7 @@ Deno.serve(async (req) => { for (const handle of candidates.slice(0, 6)) { try { const apifyRes = await fetch( - `${APIFY_BASE}/acts/apify~instagram-profile-scraper/runs?token=${APIFY_TOKEN}&waitForFinish=30`, + `${APIFY_BASE}/acts/apify~instagram-profile-scraper/runs?token=${APIFY_TOKEN}&waitForFinish=45`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/supabase/functions/enrich-channels/index.ts b/supabase/functions/enrich-channels/index.ts index 30b3437..eb36dc0 100644 --- a/supabase/functions/enrich-channels/index.ts +++ b/supabase/functions/enrich-channels/index.ts @@ -168,7 +168,8 @@ Deno.serve(async (req) => { reviewCount: place.reviewsCount, address: place.address, phone: place.phone, - website: place.website, + clinicWebsite: place.website, + mapsUrl: place.url || (place.title ? `https://www.google.com/maps/search/${encodeURIComponent(String(place.title))}` : ''), category: place.categoryName, openingHours: place.openingHours, topReviews: ((place.reviews as Record[]) || []) @@ -233,7 +234,7 @@ Deno.serve(async (req) => { url: hospitalUrl, formats: ["json"], jsonOptions: { - prompt: "Extract: hospital name, overall rating (out of 10), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges", + prompt: "Extract: hospital name, overall rating (강남언니 rating is always out of 10, NOT out of 5), total review count, doctors with names/ratings/review counts/specialties, procedures offered, address, certifications/badges", schema: { type: "object", properties: { @@ -267,7 +268,8 @@ Deno.serve(async (req) => { if (hospital?.hospitalName) { enrichment.gangnamUnni = { name: hospital.hospitalName, - rating: hospital.rating, + rawRating: hospital.rating, + rating: typeof hospital.rating === 'number' && hospital.rating > 0 && hospital.rating <= 5 ? hospital.rating * 2 : hospital.rating, ratingScale: "/10", totalReviews: hospital.totalReviews, doctors: (hospital.doctors || []).slice(0, 10), @@ -316,27 +318,39 @@ Deno.serve(async (req) => { })() ); - // 4b. Local search — Naver Place + // 4b. Local search — Naver Place (with category filtering to avoid same-name clinics) tasks.push( (async () => { - const query = encodeURIComponent(clinicName); - const res = await fetch( - `https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, - { headers: naverHeaders } - ); - if (!res.ok) return; - const data = await res.json(); - const place = (data.items || [])[0]; - if (place) { - enrichment.naverPlace = { - name: (place.title || "").replace(/<[^>]*>/g, ""), - category: place.category, - address: place.roadAddress || place.address, - telephone: place.telephone, - link: place.link, - mapx: place.mapx, - mapy: place.mapy, - }; + const queries = [`${clinicName} 성형외과`, `${clinicName} 성형`, clinicName]; + for (const q of queries) { + const query = encodeURIComponent(q); + const res = await fetch( + `https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, + { headers: naverHeaders } + ); + if (!res.ok) continue; + const data = await res.json(); + const items = (data.items || []) as Record[]; + // Prefer category matching 성형 or 피부 + const match = items.find(i => + (i.category || '').includes('성형') || (i.category || '').includes('피부') + ) || items.find(i => { + const name = (i.title || '').replace(/<[^>]*>/g, '').toLowerCase(); + return name.includes(clinicName.replace(/성형외과|병원|의원/g, '').trim().toLowerCase()); + }) || null; + + if (match) { + enrichment.naverPlace = { + name: (match.title || "").replace(/<[^>]*>/g, ""), + category: match.category, + address: match.roadAddress || match.address, + telephone: match.telephone, + link: match.link, + mapx: match.mapx, + mapy: match.mapy, + }; + break; + } } })() ); diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts index 7ffc61b..642959e 100644 --- a/supabase/functions/generate-report/index.ts +++ b/supabase/functions/generate-report/index.ts @@ -1,6 +1,7 @@ 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": "*", @@ -110,7 +111,7 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", + 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 }, @@ -217,7 +218,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2).slice(0, 4000)} method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` }, body: JSON.stringify({ - model: "sonar", + 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 },