feat: Naver Search API + multi-account Instagram + button UX fix

- Naver Blog search: collect blog post results for clinic name (total count + top 10 posts)
- Naver Place search: collect place info (name, category, address, telephone)
- Multi-account Instagram: AI prompt requests all IG accounts (국내/해외)
- enrich-channels: process multiple IG handles with fallback per handle
- transformReport: merge multiple IG accounts into instagramAudit.accounts[]
- generate-report: socialHandles.instagram now array of handles
- Hero/CTA: transition-all → transition-shadow for instant click response
- Hero/CTA: disabled state when URL is empty (opacity-50 + cursor-not-allowed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-03 15:34:10 +09:00
parent cf482d1bd7
commit 72ea8f4a2d
8 changed files with 240 additions and 68 deletions

View File

@ -52,9 +52,13 @@ export default function CTA() {
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()} onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
className="w-full px-8 py-4 text-base font-medium bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] border border-white/20 rounded-full focus:outline-none focus:ring-2 focus:ring-white/50 shadow-sm text-center text-primary-900 placeholder:text-primary-900/60" className="w-full px-8 py-4 text-base font-medium bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] border border-white/20 rounded-full focus:outline-none focus:ring-2 focus:ring-white/50 shadow-sm text-center text-primary-900 placeholder:text-primary-900/60"
/> />
<button onClick={handleAnalyze} className="w-full px-10 py-4 text-lg font-medium text-white rounded-full transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:from-[#AF90FF] hover:to-[#AF90FF]"> <button
onClick={handleAnalyze}
disabled={!url.trim()}
className={`w-full px-10 py-4 text-lg font-medium text-white rounded-full transition-shadow duration-150 shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:from-[#AF90FF] hover:to-[#AF90FF] active:scale-[0.98] ${!url.trim() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Analyze Analyze
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1" />
</button> </button>
<p className="text-sm text-purple-200/80 mt-2"> <p className="text-sm text-purple-200/80 mt-2">
, , , ,

View File

@ -64,9 +64,13 @@ export default function Hero() {
className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300" className="w-full px-8 py-5 text-base font-medium bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-center text-primary-900 placeholder:text-slate-400 transition-all group-hover:border-slate-300"
/> />
</div> </div>
<button onClick={handleAnalyze} className="w-full px-8 py-5 text-base font-bold text-white rounded-2xl transition-all shadow-xl hover:shadow-2xl flex items-center justify-center gap-3 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:scale-[1.02] active:scale-[0.98]"> <button
onClick={handleAnalyze}
disabled={!url.trim()}
className={`w-full px-8 py-5 text-base font-bold text-white rounded-2xl transition-shadow duration-150 shadow-xl hover:shadow-2xl flex items-center justify-center gap-3 group bg-gradient-to-r from-[#4F1DA1] to-[#021341] active:scale-[0.98] ${!url.trim() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Analyze Marketing Performance Analyze Marketing Performance
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1" />
</button> </button>
<p className="text-xs font-medium text-slate-500 mt-2"> <p className="text-xs font-medium text-slate-500 mt-2">
, , Online Presence . , , Online Presence .

View File

@ -14,6 +14,7 @@ interface EnrichmentParams {
reportId: string | null; reportId: string | null;
clinicName: string; clinicName: string;
instagramHandle?: string; instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string; youtubeChannelId?: string;
address?: string; address?: string;
} }
@ -34,7 +35,7 @@ export function useEnrichment(
useEffect(() => { useEffect(() => {
if (!baseReport || !params?.reportId || hasTriggered.current) return; if (!baseReport || !params?.reportId || hasTriggered.current) return;
// Don't enrich if no social handles are available // Don't enrich if no social handles are available
if (!params.instagramHandle && !params.youtubeChannelId) return; if (!params.instagramHandle && !params.instagramHandles?.length && !params.youtubeChannelId) return;
hasTriggered.current = true; hasTriggered.current = true;
setStatus('loading'); setStatus('loading');
@ -43,6 +44,7 @@ export function useEnrichment(
reportId: params.reportId, reportId: params.reportId,
clinicName: params.clinicName, clinicName: params.clinicName,
instagramHandle: params.instagramHandle, instagramHandle: params.instagramHandle,
instagramHandles: params.instagramHandles,
youtubeChannelId: params.youtubeChannelId, youtubeChannelId: params.youtubeChannelId,
address: params.address, address: params.address,
}) })

View File

@ -37,6 +37,7 @@ export interface EnrichChannelsRequest {
reportId: string; reportId: string;
clinicName: string; clinicName: string;
instagramHandle?: string; instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string; youtubeChannelId?: string;
address?: string; address?: string;
} }

View File

@ -347,6 +347,15 @@ export function transformApiReport(
* Enrichment data shape from enrich-channels Edge Function. * Enrichment data shape from enrich-channels Edge Function.
*/ */
export interface EnrichmentData { export interface EnrichmentData {
instagramAccounts?: {
username?: string;
followers?: number;
following?: number;
posts?: number;
bio?: string;
isBusinessAccount?: boolean;
externalUrl?: string;
}[];
instagram?: { instagram?: {
username?: string; username?: string;
followers?: number; followers?: number;
@ -410,6 +419,26 @@ export interface EnrichmentData {
badges?: string[]; badges?: string[];
sourceUrl?: string; sourceUrl?: string;
}; };
naverBlog?: {
totalResults?: number;
searchQuery?: string;
posts?: {
title?: string;
description?: string;
link?: string;
bloggerName?: string;
postDate?: string;
}[];
};
naverPlace?: {
name?: string;
category?: string;
address?: string;
telephone?: string;
link?: string;
mapx?: string;
mapy?: string;
};
} }
/** /**
@ -422,20 +451,18 @@ export function mergeEnrichment(
): MarketingReport { ): MarketingReport {
const merged = { ...report }; const merged = { ...report };
// Instagram enrichment // Instagram enrichment — multi-account support
if (enrichment.instagram) { const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
const ig = enrichment.instagram; if (igAccounts.length > 0) {
const existingAccount = merged.instagramAudit.accounts[0];
merged.instagramAudit = { merged.instagramAudit = {
...merged.instagramAudit, ...merged.instagramAudit,
accounts: [{ accounts: igAccounts.map((ig, idx) => ({
handle: ig.username || existingAccount?.handle || '', handle: ig.username || '',
language: 'KR', language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN',
label: '메인', label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`,
posts: ig.posts ?? existingAccount?.posts ?? 0, posts: ig.posts ?? 0,
followers: ig.followers ?? existingAccount?.followers ?? 0, followers: ig.followers ?? 0,
following: ig.following ?? existingAccount?.following ?? 0, following: ig.following ?? 0,
category: '의료/건강', category: '의료/건강',
profileLink: ig.username ? `https://instagram.com/${ig.username}` : '', profileLink: ig.username ? `https://instagram.com/${ig.username}` : '',
highlights: [], highlights: [],
@ -443,7 +470,7 @@ export function mergeEnrichment(
contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정', contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정',
profilePhoto: '', profilePhoto: '',
bio: ig.bio || '', bio: ig.bio || '',
}], })),
}; };
// Update KPI with real follower data // Update KPI with real follower data
@ -589,5 +616,44 @@ export function mergeEnrichment(
} }
} }
// 네이버 블로그 enrichment
if (enrichment.naverBlog) {
const nb = enrichment.naverBlog;
const nbChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 블로그');
const nbChannel = {
name: '네이버 블로그',
status: 'active' as const,
details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}`,
url: '',
};
if (nbChannelIdx >= 0) {
merged.otherChannels[nbChannelIdx] = nbChannel;
} else {
merged.otherChannels = [...merged.otherChannels, nbChannel];
}
}
// 네이버 플레이스 enrichment
if (enrichment.naverPlace) {
const np = enrichment.naverPlace;
const npChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 플레이스');
const npChannel = {
name: '네이버 플레이스',
status: 'active' as const,
details: np.category || '',
url: np.link || '',
};
if (npChannelIdx >= 0) {
merged.otherChannels[npChannelIdx] = npChannel;
} else {
merged.otherChannels = [...merged.otherChannels, npChannel];
}
// Update clinic phone/address from Naver Place if available
if (np.telephone) {
merged.clinicSnapshot = { ...merged.clinicSnapshot, phone: np.telephone };
}
}
return merged; return merged;
} }

View File

@ -53,10 +53,10 @@ export default function ReportPage() {
const handles = stateSocialHandles || dbSocialHandles; const handles = stateSocialHandles || dbSocialHandles;
const igHandle = // Instagram: support array of handles (multi-account) or single handle
handles?.instagram || const igHandles: string[] = Array.isArray(handles?.instagram)
baseData.instagramAudit?.accounts?.[0]?.handle || ? handles.instagram.filter(Boolean) as string[]
undefined; : handles?.instagram ? [handles.instagram as string] : [];
const ytHandle = const ytHandle =
handles?.youtube || handles?.youtube ||
@ -66,7 +66,7 @@ export default function ReportPage() {
return { return {
reportId: baseData.id, reportId: baseData.id,
clinicName: baseData.clinicSnapshot.name, clinicName: baseData.clinicSnapshot.name,
instagramHandle: igHandle || undefined, instagramHandles: igHandles.length > 0 ? igHandles : undefined,
youtubeChannelId: ytHandle || undefined, youtubeChannelId: ytHandle || undefined,
address: baseData.clinicSnapshot.location || undefined, address: baseData.clinicSnapshot.location || undefined,
}; };

View File

@ -14,6 +14,7 @@ interface EnrichRequest {
reportId: string; reportId: string;
clinicName: string; clinicName: string;
instagramHandle?: string; instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string; youtubeChannelId?: string;
address?: string; address?: string;
} }
@ -47,9 +48,14 @@ Deno.serve(async (req) => {
} }
try { try {
const { reportId, clinicName, instagramHandle, youtubeChannelId, address } = const { reportId, clinicName, instagramHandle, instagramHandles, youtubeChannelId, address } =
(await req.json()) as EnrichRequest; (await req.json()) as EnrichRequest;
// Build list of IG handles to try: explicit array > single handle > empty
const igHandlesToTry: string[] = instagramHandles?.length
? instagramHandles
: instagramHandle ? [instagramHandle] : [];
const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN"); const APIFY_TOKEN = Deno.env.get("APIFY_API_TOKEN");
if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured"); if (!APIFY_TOKEN) throw new Error("APIFY_API_TOKEN not configured");
@ -58,21 +64,30 @@ Deno.serve(async (req) => {
// Run all enrichment tasks in parallel // Run all enrichment tasks in parallel
const tasks = []; const tasks = [];
// 1. Instagram Profile — with fallback for wrong handle // 1. Instagram Profiles — multi-account with fallback
const cleanIgHandle = normalizeInstagramHandle(instagramHandle); if (igHandlesToTry.length > 0) {
if (cleanIgHandle) {
tasks.push( tasks.push(
(async () => { (async () => {
// Try the given handle first, then common clinic variants const accounts: Record<string, unknown>[] = [];
const handleCandidates = [ const triedHandles = new Set<string>();
cleanIgHandle,
`${cleanIgHandle}_ps`, // banobagi → banobagi_ps for (const rawHandle of igHandlesToTry) {
`${cleanIgHandle}.ps`, // banobagi → banobagi.ps const baseHandle = normalizeInstagramHandle(rawHandle);
`${cleanIgHandle}_clinic`, // banobagi → banobagi_clinic if (!baseHandle || triedHandles.has(baseHandle)) continue;
`${cleanIgHandle}_official`, // banobagi → banobagi_official
// Try the handle + common clinic variants
const candidates = [
baseHandle,
`${baseHandle}_ps`,
`${baseHandle}.ps`,
`${baseHandle}_clinic`,
`${baseHandle}_official`,
]; ];
for (const handle of handleCandidates) { for (const handle of candidates) {
if (triedHandles.has(handle)) continue;
triedHandles.add(handle);
const items = await runApifyActor( const items = await runApifyActor(
"apify~instagram-profile-scraper", "apify~instagram-profile-scraper",
{ usernames: [handle], resultsLimit: 12 }, { usernames: [handle], resultsLimit: 12 },
@ -82,10 +97,8 @@ Deno.serve(async (req) => {
if (profile && !profile.error) { if (profile && !profile.error) {
const followers = (profile.followersCount as number) || 0; const followers = (profile.followersCount as number) || 0;
// Accept if: has meaningful followers OR is a business account with posts
if (followers >= 100 || ((profile.isBusinessAccount as boolean) && (profile.postsCount as number) > 10)) { if (followers >= 100 || ((profile.isBusinessAccount as boolean) && (profile.postsCount as number) > 10)) {
enrichment.instagram = { accounts.push({
username: profile.username, username: profile.username,
followers: profile.followersCount, followers: profile.followersCount,
following: profile.followsCount, following: profile.followsCount,
@ -94,7 +107,7 @@ Deno.serve(async (req) => {
isBusinessAccount: profile.isBusinessAccount, isBusinessAccount: profile.isBusinessAccount,
externalUrl: profile.externalUrl, externalUrl: profile.externalUrl,
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || []) latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
.slice(0, 12) .slice(0, 6)
.map((p) => ({ .map((p) => ({
type: p.type, type: p.type,
likes: p.likesCount, likes: p.likesCount,
@ -102,11 +115,19 @@ Deno.serve(async (req) => {
caption: (p.caption as string || "").slice(0, 200), caption: (p.caption as string || "").slice(0, 200),
timestamp: p.timestamp, timestamp: p.timestamp,
})), })),
}; });
break; // Found a valid account break; // Found valid for this base handle, move to next
} }
} }
} }
}
// Store as array for multi-account support
if (accounts.length > 0) {
enrichment.instagramAccounts = accounts;
// Keep backwards compat: first account as enrichment.instagram
enrichment.instagram = accounts[0];
}
})() })()
); );
} }
@ -245,7 +266,68 @@ Deno.serve(async (req) => {
} }
} }
// 4. YouTube Channel (using YouTube Data API v3) // 4. Naver Blog + Place Search (네이버 검색 API)
if (clinicName) {
const NAVER_CLIENT_ID = Deno.env.get("NAVER_CLIENT_ID");
const NAVER_CLIENT_SECRET = Deno.env.get("NAVER_CLIENT_SECRET");
if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET) {
const naverHeaders = {
"X-Naver-Client-Id": NAVER_CLIENT_ID,
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET,
};
// 4a. Blog search — "{clinicName} 후기"
tasks.push(
(async () => {
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();
enrichment.naverBlog = {
totalResults: data.total || 0,
searchQuery: `${clinicName} 후기`,
posts: (data.items || []).slice(0, 10).map((item: Record<string, string>) => ({
title: (item.title || "").replace(/<[^>]*>/g, ""),
description: (item.description || "").replace(/<[^>]*>/g, "").slice(0, 200),
link: item.link,
bloggerName: item.bloggername,
postDate: item.postdate,
})),
};
})()
);
// 4b. Local search — Naver Place
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,
};
}
})()
);
}
}
// 5. YouTube Channel (using YouTube Data API v3)
if (youtubeChannelId) { if (youtubeChannelId) {
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY"); const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
if (YOUTUBE_API_KEY) { if (YOUTUBE_API_KEY) {

View File

@ -94,7 +94,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
"services": ["시술1", "시술2"], "services": ["시술1", "시술2"],
"doctors": [{"name": "의사명", "specialty": "전문분야"}], "doctors": [{"name": "의사명", "specialty": "전문분야"}],
"socialMedia": { "socialMedia": {
"instagram": "정확한 Instagram 핸들 (@ 없이, 예: banobagi_ps)", "instagramAccounts": ["국내용 핸들", "해외/영문 핸들", "기타 관련 계정 (@ 없이, 예: banobagi_ps, english_banobagi)"],
"youtube": "YouTube 채널 핸들 또는 URL", "youtube": "YouTube 채널 핸들 또는 URL",
"facebook": "Facebook 페이지명", "facebook": "Facebook 페이지명",
"naverBlog": "네이버 블로그 ID" "naverBlog": "네이버 블로그 ID"
@ -174,8 +174,21 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
// Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback) // Merge social handles: AI-found (more accurate) > Firecrawl-extracted (fallback)
const scrapeSocial = clinic.socialMedia || {}; const scrapeSocial = clinic.socialMedia || {};
const aiSocial = report?.clinicInfo?.socialMedia || {}; const aiSocial = report?.clinicInfo?.socialMedia || {};
// Instagram: collect all accounts from AI + Firecrawl, deduplicate
const aiIgAccounts: string[] = Array.isArray(aiSocial.instagramAccounts)
? aiSocial.instagramAccounts
: aiSocial.instagram ? [aiSocial.instagram] : [];
const scrapeIg = scrapeSocial.instagram ? [scrapeSocial.instagram] : [];
const allIgRaw = [...aiIgAccounts, ...scrapeIg];
const igHandles = [...new Set(
allIgRaw
.map((h: string) => normalizeInstagramHandle(h))
.filter((h): h is string => h !== null)
)];
const normalizedHandles = { const normalizedHandles = {
instagram: normalizeInstagramHandle(aiSocial.instagram) || normalizeInstagramHandle(scrapeSocial.instagram), instagram: igHandles.length > 0 ? igHandles : null, // array of handles
youtube: aiSocial.youtube || scrapeSocial.youtube || null, youtube: aiSocial.youtube || scrapeSocial.youtube || null,
facebook: aiSocial.facebook || scrapeSocial.facebook || null, facebook: aiSocial.facebook || scrapeSocial.facebook || null,
blog: aiSocial.naverBlog || scrapeSocial.blog || null, blog: aiSocial.naverBlog || scrapeSocial.blog || null,