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
parent
5ed35bc4cd
commit
c0c37b84de
|
|
@ -1,6 +1,6 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { ShieldAlert, Layers, Link2 } from 'lucide-react';
|
|
||||||
import { SectionWrapper } from './ui/SectionWrapper';
|
import { SectionWrapper } from './ui/SectionWrapper';
|
||||||
|
import { ShieldFilled, FileTextFilled, LinkExternalFilled } from '../icons/FilledIcons';
|
||||||
import type { DiagnosisItem } from '../../types/report';
|
import type { DiagnosisItem } from '../../types/report';
|
||||||
|
|
||||||
interface ProblemDiagnosisProps {
|
interface ProblemDiagnosisProps {
|
||||||
|
|
@ -13,7 +13,7 @@ interface ProblemDiagnosisProps {
|
||||||
* into the key strategic buckets that the reference design shows.
|
* into the key strategic buckets that the reference design shows.
|
||||||
*/
|
*/
|
||||||
function clusterDiagnosis(items: DiagnosisItem[]): {
|
function clusterDiagnosis(items: DiagnosisItem[]): {
|
||||||
icon: typeof ShieldAlert;
|
icon: typeof ShieldFilled;
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
}[] {
|
}[] {
|
||||||
|
|
@ -64,27 +64,27 @@ function clusterDiagnosis(items: DiagnosisItem[]): {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
icon: ShieldAlert,
|
icon: ShieldFilled,
|
||||||
title: '브랜드 아이덴티티 파편화',
|
title: '브랜드 아이덴티티 파편화',
|
||||||
detail:
|
detail:
|
||||||
brandItems.length > 0
|
brandItems.length > 0
|
||||||
? brandItems.slice(0, 3).join(' ')
|
? brandItems.slice(0, 3).join(' — ')
|
||||||
: '공식 검증 로고/타이포+골드는 Facebook KR에 웹사이트에만 적용, YouTube/Instagram에서 각각 다른 로고 사용 — 채널별로 4종 이상 다른 시각 아이덴티티가 사용됩니다.',
|
: '공식 검증 로고/타이포+골드는 Facebook KR에 웹사이트에만 적용, YouTube/Instagram에서 각각 다른 로고 사용 — 채널별로 4종 이상 다른 시각 아이덴티티가 사용됩니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Layers,
|
icon: FileTextFilled,
|
||||||
title: '콘텐츠 전략 부재',
|
title: '콘텐츠 전략 부재',
|
||||||
detail:
|
detail:
|
||||||
contentItems.length > 0
|
contentItems.length > 0
|
||||||
? contentItems.slice(0, 3).join(' ')
|
? contentItems.slice(0, 3).join(' — ')
|
||||||
: '콘텐츠 캘린더가 없음, 콘텐츠 기/가이드 없음, KR+EN 시장 타겟 전략 없음. YouTube→Instagram 크로스 포스팅 부재.',
|
: '콘텐츠 캘린더가 없음, 콘텐츠 기/가이드 없음, KR+EN 시장 타겟 전략 없음. YouTube→Instagram 크로스 포스팅 부재.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Link2,
|
icon: LinkExternalFilled,
|
||||||
title: '플랫폼 간 유입 단절',
|
title: '플랫폼 간 유입 단절',
|
||||||
detail:
|
detail:
|
||||||
funnelItems.length > 0
|
funnelItems.length > 0
|
||||||
? funnelItems.slice(0, 3).join(' ')
|
? funnelItems.slice(0, 3).join(' — ')
|
||||||
: 'YouTube 10만+ → Instagram 1.4K 전환 실패, 웹사이트에서 SNS 유입 3% 미만, 상담전환 동선 부재.',
|
: 'YouTube 10만+ → Instagram 1.4K 전환 실패, 웹사이트에서 SNS 유입 3% 미만, 상담전환 동선 부재.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
||||||
// AI-generated per-channel diagnosis array (new format)
|
// AI-generated per-channel diagnosis array (new format)
|
||||||
if (ch.diagnosis) {
|
if (ch.diagnosis) {
|
||||||
for (const d of 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) {
|
if (issue) {
|
||||||
const rec = typeof d.recommendation === 'string' ? d.recommendation : '';
|
const rec = typeof d.recommendation === 'string' ? d.recommendation : '';
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -159,7 +159,15 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
|
||||||
}
|
}
|
||||||
if (ch.issues) {
|
if (ch.issues) {
|
||||||
for (const issue of 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' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -458,115 +458,126 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 5. Naver Blog + Place ───
|
// ─── 5. Naver Blog + Place ───
|
||||||
if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET && clinicName) {
|
// Architecture: DB-first. Use verified_channels URLs directly — no speculative live search.
|
||||||
const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET };
|
// 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<string, unknown> | null;
|
||||||
|
const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null;
|
||||||
|
|
||||||
|
if (officialBlogHandle) {
|
||||||
channelTasks.push(wrapChannelTask("naverBlog", async () => {
|
channelTasks.push(wrapChannelTask("naverBlog", async () => {
|
||||||
// Get verified Naver Blog handle from Phase 1 for official blog URL
|
const officialBlogUrl = `https://blog.naver.com/${officialBlogHandle}`;
|
||||||
const nbVerified = verified.naverBlog as Record<string, unknown> | null;
|
try {
|
||||||
const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null;
|
const rssRes = await fetchWithRetry(
|
||||||
const officialBlogUrl = officialBlogHandle ? `https://blog.naver.com/${officialBlogHandle}` : null;
|
`https://rss.blog.naver.com/${officialBlogHandle}.xml`,
|
||||||
|
{},
|
||||||
// ─── 5a. Naver Search: 3rd-party blog mentions ───
|
{ label: "naver-rss", timeoutMs: 15000 }
|
||||||
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 (!rssRes.ok) throw new Error(`RSS ${rssRes.status}`);
|
||||||
if (!res.ok) throw new Error(`Naver Blog API returned ${res.status}`);
|
const xml = await rssRes.text();
|
||||||
const data = await res.json();
|
const items: Array<{ title: string; link: string; date: string; excerpt: string }> = [];
|
||||||
|
for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
||||||
// ─── 5b. Naver RSS: Official blog recent posts ───
|
const b = m[1];
|
||||||
// blog.naver.com is blocked by Firecrawl. Use the public RSS feed instead:
|
const title = (b.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/) || b.match(/<title>(.*?)<\/title>/))?.[1] || "";
|
||||||
// https://rss.blog.naver.com/{blogId}.xml — no auth required.
|
const link = b.match(/<link>(.*?)<\/link>/)?.[1] || "";
|
||||||
let officialBlogContent: Record<string, unknown> | null = null;
|
const date = b.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
|
||||||
if (officialBlogHandle) {
|
const desc = (b.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>/) || b.match(/<description>(.*?)<\/description>/))?.[1] || "";
|
||||||
try {
|
items.push({ title, link, date, excerpt: desc.replace(/<[^>]*>/g, "").trim().slice(0, 150) });
|
||||||
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: <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] || "";
|
|
||||||
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 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 () => {
|
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 = [
|
const queries = [
|
||||||
|
...(district ? [`${clinicName} ${district}`, `${clinicName} 성형 ${district}`] : []),
|
||||||
`${clinicName} 성형외과`,
|
`${clinicName} 성형외과`,
|
||||||
`${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) {
|
for (const q of queries) {
|
||||||
const query = encodeURIComponent(q);
|
const res = await fetchWithRetry(
|
||||||
const res = await fetchWithRetry(`https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, { headers: naverHeaders }, { label: "naver-place" });
|
`https://openapi.naver.com/v1/search/local.json?query=${encodeURIComponent(q)}&display=5&sort=comment`,
|
||||||
|
{ headers: naverHeaders },
|
||||||
|
{ label: "naver-place" }
|
||||||
|
);
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = (data.items || []) as Record<string, string>[];
|
const items = (data.items || []) as Record<string, string>[];
|
||||||
|
|
||||||
const normalize = (s: string) => (s || '').replace(/<[^>]*>/g, '').toLowerCase();
|
// Match by official domain first (most reliable), then exact name + 성형 category
|
||||||
|
const match = (clinicDomain ? items.find(i => (i.link || '').includes(clinicDomain)) : null)
|
||||||
// Priority 1: name contains full clinicName (exact match, ignoring HTML tags)
|
?? items.find(i => normalize(i.title) === clinicName.toLowerCase() && (i.category || '').includes('성형'))
|
||||||
let match = items.find(i => normalize(i.title).includes(clinicName.toLowerCase())) || null;
|
?? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
channelData.naverPlace = {
|
found = {
|
||||||
name: normalize(match.title),
|
name: normalize(match.title), category: match.category,
|
||||||
category: match.category, address: match.roadAddress || match.address,
|
address: match.roadAddress || match.address,
|
||||||
telephone: match.telephone, link: match.link, mapx: match.mapx, mapy: match.mapy,
|
telephone: match.telephone, link: match.link,
|
||||||
|
mapx: match.mapx, mapy: match.mapy,
|
||||||
};
|
};
|
||||||
|
console.log(`[naverPlace] Found via "${q}": ${found.name}`);
|
||||||
break;
|
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`);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue