From c0c37b84de74c23999c08bf0f5877fbb48aa6cb1 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 7 Apr 2026 17:17:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20naverBlog=20RSS=20=EC=A0=84=ED=99=98=20+?= =?UTF-8?q?=20naverPlace=20DB-first=20=ED=8C=A8=ED=84=B4=20+=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=EB=AC=B8=EC=A0=9C=EC=A7=84=EB=8B=A8=20JSON=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collect-channel-data: naverBlog 실시간 검색 제거 → verified handle 기반 RSS 직접 fetch - collect-channel-data: naverPlace DB-first 패턴 (verified_channels에 저장된 데이터 우선 사용, 없을 때만 URL도메인 매칭 검색 후 DB에 저장) - transformReport: ch.issues 배열 항목이 {issue, severity} 객체일 때 JSON.stringify 대신 .issue 문자열 추출 - ProblemDiagnosis: Lucide 아이콘 제거 → FilledIcons(ShieldFilled, FileTextFilled, LinkExternalFilled), 항목 구분자 ' ' → ' — ' Co-Authored-By: Claude Sonnet 4.6 --- src/components/report/ProblemDiagnosis.tsx | 16 +- src/lib/transformReport.ts | 12 +- .../functions/collect-channel-data/index.ts | 183 ++++++++++-------- 3 files changed, 115 insertions(+), 96 deletions(-) diff --git a/src/components/report/ProblemDiagnosis.tsx b/src/components/report/ProblemDiagnosis.tsx index 93841ad..b6e443b 100644 --- a/src/components/report/ProblemDiagnosis.tsx +++ b/src/components/report/ProblemDiagnosis.tsx @@ -1,6 +1,6 @@ import { motion } from 'motion/react'; -import { ShieldAlert, Layers, Link2 } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; +import { ShieldFilled, FileTextFilled, LinkExternalFilled } from '../icons/FilledIcons'; import type { DiagnosisItem } from '../../types/report'; interface ProblemDiagnosisProps { @@ -13,7 +13,7 @@ interface ProblemDiagnosisProps { * into the key strategic buckets that the reference design shows. */ function clusterDiagnosis(items: DiagnosisItem[]): { - icon: typeof ShieldAlert; + icon: typeof ShieldFilled; title: string; detail: string; }[] { @@ -64,27 +64,27 @@ function clusterDiagnosis(items: DiagnosisItem[]): { return [ { - icon: ShieldAlert, + icon: ShieldFilled, title: '브랜드 아이덴티티 파편화', detail: brandItems.length > 0 - ? brandItems.slice(0, 3).join(' ') + ? brandItems.slice(0, 3).join(' — ') : '공식 검증 로고/타이포+골드는 Facebook KR에 웹사이트에만 적용, YouTube/Instagram에서 각각 다른 로고 사용 — 채널별로 4종 이상 다른 시각 아이덴티티가 사용됩니다.', }, { - icon: Layers, + icon: FileTextFilled, title: '콘텐츠 전략 부재', detail: contentItems.length > 0 - ? contentItems.slice(0, 3).join(' ') + ? contentItems.slice(0, 3).join(' — ') : '콘텐츠 캘린더가 없음, 콘텐츠 기/가이드 없음, KR+EN 시장 타겟 전략 없음. YouTube→Instagram 크로스 포스팅 부재.', }, { - icon: Link2, + icon: LinkExternalFilled, title: '플랫폼 간 유입 단절', detail: funnelItems.length > 0 - ? funnelItems.slice(0, 3).join(' ') + ? funnelItems.slice(0, 3).join(' — ') : 'YouTube 10만+ → Instagram 1.4K 전환 실패, 웹사이트에서 SNS 유입 3% 미만, 상담전환 동선 부재.', }, ]; diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts index 75a1dc5..60139aa 100644 --- a/src/lib/transformReport.ts +++ b/src/lib/transformReport.ts @@ -138,7 +138,7 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] { // AI-generated per-channel diagnosis array (new format) if (ch.diagnosis) { for (const d of ch.diagnosis) { - const issue = typeof d.issue === 'string' ? d.issue : typeof d === 'string' ? d : JSON.stringify(d.issue ?? d); + const issue = typeof d === 'string' ? d : typeof d.issue === 'string' ? d.issue : null; if (issue) { const rec = typeof d.recommendation === 'string' ? d.recommendation : ''; items.push({ @@ -159,7 +159,15 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] { } if (ch.issues) { for (const issue of ch.issues) { - items.push({ category: channel, detail: typeof issue === 'string' ? issue : JSON.stringify(issue), severity: 'warning' }); + const issueObj = issue as Record; + const detail = typeof issue === 'string' + ? issue + : typeof issueObj.issue === 'string' + ? issueObj.issue + : null; + if (detail) { + items.push({ category: channel, detail, severity: (issueObj.severity as Severity) || 'warning' }); + } } } } diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts index 9bdea42..e9df3cc 100644 --- a/supabase/functions/collect-channel-data/index.ts +++ b/supabase/functions/collect-channel-data/index.ts @@ -458,115 +458,126 @@ Deno.serve(async (req) => { } // ─── 5. Naver Blog + Place ─── - if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET && clinicName) { - const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET }; + // Architecture: DB-first. Use verified_channels URLs directly — no speculative live search. + // naverBlog: verified handle → RSS feed only. + // naverPlace: if already stored in verified_channels → use as-is. + // if not stored → search once with domain matching, then save to DB. + // naverBlog: DB-verified handle → RSS only (no live "후기" search) + const nbVerified = verified.naverBlog as Record | null; + const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null; + + if (officialBlogHandle) { channelTasks.push(wrapChannelTask("naverBlog", 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 officialBlogUrl = officialBlogHandle ? `https://blog.naver.com/${officialBlogHandle}` : null; - - // ─── 5a. Naver Search: 3rd-party blog mentions ─── - const query = encodeURIComponent(`${clinicName} 후기`); - const res = await fetchWithRetry(`https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, { headers: naverHeaders }, { label: "naver-blog" }); - if (!res.ok) throw new Error(`Naver Blog API returned ${res.status}`); - const data = await res.json(); - - // ─── 5b. Naver RSS: Official blog recent posts ─── - // blog.naver.com is blocked by Firecrawl. Use the public RSS feed instead: - // https://rss.blog.naver.com/{blogId}.xml — no auth required. - let officialBlogContent: Record | null = null; - if (officialBlogHandle) { - try { - const rssRes = await fetchWithRetry( - `https://rss.blog.naver.com/${officialBlogHandle}.xml`, - {}, - { label: "naver-rss", timeoutMs: 15000 } - ); - if (rssRes.ok) { - const xml = await rssRes.text(); - // Parse RSS items: ............ - const items: Array<{ title: string; link: string; date: string; excerpt: string }> = []; - const itemMatches = xml.matchAll(/([\s\S]*?)<\/item>/g); - for (const m of itemMatches) { - const block = m[1]; - const title = (block.match(/<!\[CDATA\[(.*?)\]\]><\/title>/) || block.match(/<title>(.*?)<\/title>/))?.[1] || ""; - const link = (block.match(/<link>(.*?)<\/link>/))?.[1] || ""; - const date = (block.match(/<pubDate>(.*?)<\/pubDate>/))?.[1] || ""; - const desc = (block.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>/) || block.match(/<description>(.*?)<\/description>/))?.[1] || ""; - items.push({ title, link, date, excerpt: desc.replace(/<[^>]*>/g, "").trim().slice(0, 150) }); - } - const totalPosts = (xml.match(/<totalCount>(\d+)<\/totalCount>/) || xml.match(/<managedCount>(\d+)<\/managedCount>/))?.[1]; - officialBlogContent = { - totalPosts: totalPosts ? Number(totalPosts) : items.length, - recentPosts: items.slice(0, 10), - }; - console.log(`[naverBlog] RSS fetched: ${items.length} posts from ${officialBlogHandle}`); - } - } catch (e) { - console.warn(`[naverBlog] RSS fetch failed (non-critical):`, e); + const officialBlogUrl = `https://blog.naver.com/${officialBlogHandle}`; + try { + const rssRes = await fetchWithRetry( + `https://rss.blog.naver.com/${officialBlogHandle}.xml`, + {}, + { label: "naver-rss", timeoutMs: 15000 } + ); + if (!rssRes.ok) throw new Error(`RSS ${rssRes.status}`); + const xml = await rssRes.text(); + const items: Array<{ title: string; link: string; date: string; excerpt: string }> = []; + for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) { + const b = m[1]; + const title = (b.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/) || b.match(/<title>(.*?)<\/title>/))?.[1] || ""; + const link = b.match(/<link>(.*?)<\/link>/)?.[1] || ""; + const date = b.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || ""; + const desc = (b.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>/) || b.match(/<description>(.*?)<\/description>/))?.[1] || ""; + items.push({ title, link, date, excerpt: desc.replace(/<[^>]*>/g, "").trim().slice(0, 150) }); } + const totalMatch = xml.match(/<totalCount>(\d+)<\/totalCount>/) || xml.match(/<managedCount>(\d+)<\/managedCount>/); + const totalPosts = totalMatch ? Number(totalMatch[1]) : items.length; + channelData.naverBlog = { + officialBlogUrl, officialBlogHandle, + totalResults: totalPosts, + posts: items.slice(0, 10).map(i => ({ + title: i.title, description: i.excerpt, + link: i.link, bloggerName: clinicName, postDate: i.date, + })), + officialContent: { totalPosts, recentPosts: items.slice(0, 10) }, + }; + console.log(`[naverBlog] RSS: ${items.length} posts from verified handle ${officialBlogHandle}`); + } catch (e) { + console.warn(`[naverBlog] RSS fetch failed:`, e); + // Fallback: at minimum expose the official URL even without post data + channelData.naverBlog = { officialBlogUrl, officialBlogHandle, totalResults: 0, posts: [], officialContent: null }; } - - channelData.naverBlog = { - totalResults: data.total || 0, searchQuery: `${clinicName} 후기`, - officialBlogUrl, - officialBlogHandle, - // Official blog content (from Firecrawl — actual blog data) - officialContent: officialBlogContent, - // Blog mentions (third-party posts via Naver Search) - posts: (data.items || []).slice(0, 10).map((item: Record<string, string>) => ({ - title: (item.title || "").replace(/<[^>]*>/g, ""), - description: (item.description || "").replace(/<[^>]*>/g, ""), - link: item.link, bloggerName: item.bloggername, postDate: item.postdate, - })), - }; })); + } else { + console.log(`[naverBlog] No verified handle in DB — skipping`); + } + + // naverPlace: use stored verified data if available, otherwise search once and save + if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET) { + const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET }; + const npVerified = (verified as Record<string, unknown>).naverPlace as Record<string, unknown> | null; channelTasks.push(wrapChannelTask("naverPlace", async () => { - // Try multiple queries to find the correct place (avoid same-name different clinics) + // ── Fast path: already verified in DB ── + if (npVerified?.name) { + console.log(`[naverPlace] Using verified DB data: ${npVerified.name}`); + channelData.naverPlace = npVerified; + return; + } + + // ── Slow path: first-time discovery via domain-matched search ── + const normalize = (s: string) => (s || '').replace(/<[^>]*>/g, '').toLowerCase(); + let clinicDomain = ''; + try { clinicDomain = new URL(row.url || '').hostname.replace('www.', ''); } catch { /* skip */ } + + const districtMatch = address.match(/([가-힣]+(구|동))/); + const district = districtMatch?.[1] || ''; const queries = [ + ...(district ? [`${clinicName} ${district}`, `${clinicName} 성형 ${district}`] : []), `${clinicName} 성형외과`, `${clinicName} 성형`, - clinicName, ]; - // Core name without type suffixes (e.g. "뷰성형외과" → "뷰성형외과", trimmed for short names) - const cleanedName = clinicName.replace(/성형외과|병원|의원/g, '').trim().toLowerCase(); + let found: Record<string, unknown> | null = null; for (const q of queries) { - const query = encodeURIComponent(q); - const res = await fetchWithRetry(`https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, { headers: naverHeaders }, { label: "naver-place" }); + const res = await fetchWithRetry( + `https://openapi.naver.com/v1/search/local.json?query=${encodeURIComponent(q)}&display=5&sort=comment`, + { headers: naverHeaders }, + { label: "naver-place" } + ); if (!res.ok) continue; const data = await res.json(); const items = (data.items || []) as Record<string, string>[]; - const normalize = (s: string) => (s || '').replace(/<[^>]*>/g, '').toLowerCase(); - - // Priority 1: name contains full clinicName (exact match, ignoring HTML tags) - let match = items.find(i => normalize(i.title).includes(clinicName.toLowerCase())) || null; - - // Priority 2: name contains cleaned short name AND category is 성형 (plastic surgery only) - if (!match && cleanedName.length >= 2) { - match = items.find(i => - normalize(i.title).includes(cleanedName) && (i.category || '').includes('성형') - ) || null; - } - - // Priority 3: category is 성형 (plastic surgery) — not 피부 to avoid skin clinics - if (!match) { - match = items.find(i => (i.category || '').includes('성형')) || null; - } + // Match by official domain first (most reliable), then exact name + 성형 category + const match = (clinicDomain ? items.find(i => (i.link || '').includes(clinicDomain)) : null) + ?? items.find(i => normalize(i.title) === clinicName.toLowerCase() && (i.category || '').includes('성형')) + ?? null; if (match) { - channelData.naverPlace = { - name: normalize(match.title), - category: match.category, address: match.roadAddress || match.address, - telephone: match.telephone, link: match.link, mapx: match.mapx, mapy: match.mapy, + found = { + name: normalize(match.title), category: match.category, + address: match.roadAddress || match.address, + telephone: match.telephone, link: match.link, + mapx: match.mapx, mapy: match.mapy, }; + console.log(`[naverPlace] Found via "${q}": ${found.name}`); break; } } + + if (found) { + channelData.naverPlace = found; + // Save to clinics.verified_channels so future runs skip the search + if (inputClinicId) { + const { data: clinicRow } = await supabase.from('clinics').select('verified_channels').eq('id', inputClinicId).single(); + if (clinicRow) { + await supabase.from('clinics').update({ + verified_channels: { ...(clinicRow.verified_channels as object || {}), naverPlace: found }, + }).eq('id', inputClinicId); + console.log(`[naverPlace] Saved to clinics.verified_channels for future runs`); + } + } + } else { + console.log(`[naverPlace] No confident match found — skipping to avoid wrong data`); + } })); }