fix: naverBlog RSS 전환 + naverPlace DB-first 패턴 + 핵심문제진단 JSON 렌더링 버그 수정

- 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 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 17:17:22 +09:00
parent 5ed35bc4cd
commit c0c37b84de
3 changed files with 115 additions and 96 deletions

View File

@ -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% 미만, 상담전환 동선 부재.',
},
];

View File

@ -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<string, unknown>;
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' });
}
}
}
}

View File

@ -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.
channelTasks.push(wrapChannelTask("naverBlog", async () => {
// Get verified Naver Blog handle from Phase 1 for official blog URL
// naverBlog: DB-verified handle → RSS only (no live "후기" search)
const nbVerified = verified.naverBlog as Record<string, unknown> | 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<string, unknown> | null = null;
if (officialBlogHandle) {
channelTasks.push(wrapChannelTask("naverBlog", async () => {
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) {
if (!rssRes.ok) throw new Error(`RSS ${rssRes.status}`);
const xml = await rssRes.text();
// Parse RSS items: <item><title>...</title><link>...</link><pubDate>...</pubDate><description>...</description></item>
const items: Array<{ title: string; link: string; date: string; excerpt: string }> = [];
const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g);
for (const m of itemMatches) {
const block = m[1];
const title = (block.match(/<title><!\[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] || "";
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 totalPosts = (xml.match(/<totalCount>(\d+)<\/totalCount>/) || xml.match(/<managedCount>(\d+)<\/managedCount>/))?.[1];
officialBlogContent = {
totalPosts: totalPosts ? Number(totalPosts) : items.length,
recentPosts: items.slice(0, 10),
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 fetched: ${items.length} posts from ${officialBlogHandle}`);
}
console.log(`[naverBlog] RSS: ${items.length} posts from verified handle ${officialBlogHandle}`);
} catch (e) {
console.warn(`[naverBlog] RSS fetch failed (non-critical):`, 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 };
}
}));
} else {
console.log(`[naverBlog] No verified handle in DB — skipping`);
}
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,
})),
};
}));
// 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`);
}
}));
}