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()}
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
<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>
<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"
/>
</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
<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>
<p className="text-xs font-medium text-slate-500 mt-2">
, , Online Presence .

View File

@ -14,6 +14,7 @@ interface EnrichmentParams {
reportId: string | null;
clinicName: string;
instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string;
address?: string;
}
@ -34,7 +35,7 @@ export function useEnrichment(
useEffect(() => {
if (!baseReport || !params?.reportId || hasTriggered.current) return;
// 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;
setStatus('loading');
@ -43,6 +44,7 @@ export function useEnrichment(
reportId: params.reportId,
clinicName: params.clinicName,
instagramHandle: params.instagramHandle,
instagramHandles: params.instagramHandles,
youtubeChannelId: params.youtubeChannelId,
address: params.address,
})

View File

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

View File

@ -347,6 +347,15 @@ export function transformApiReport(
* Enrichment data shape from enrich-channels Edge Function.
*/
export interface EnrichmentData {
instagramAccounts?: {
username?: string;
followers?: number;
following?: number;
posts?: number;
bio?: string;
isBusinessAccount?: boolean;
externalUrl?: string;
}[];
instagram?: {
username?: string;
followers?: number;
@ -410,6 +419,26 @@ export interface EnrichmentData {
badges?: 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 {
const merged = { ...report };
// Instagram enrichment
if (enrichment.instagram) {
const ig = enrichment.instagram;
const existingAccount = merged.instagramAudit.accounts[0];
// Instagram enrichment — multi-account support
const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []);
if (igAccounts.length > 0) {
merged.instagramAudit = {
...merged.instagramAudit,
accounts: [{
handle: ig.username || existingAccount?.handle || '',
language: 'KR',
label: '메인',
posts: ig.posts ?? existingAccount?.posts ?? 0,
followers: ig.followers ?? existingAccount?.followers ?? 0,
following: ig.following ?? existingAccount?.following ?? 0,
accounts: igAccounts.map((ig, idx) => ({
handle: ig.username || '',
language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN',
label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`,
posts: ig.posts ?? 0,
followers: ig.followers ?? 0,
following: ig.following ?? 0,
category: '의료/건강',
profileLink: ig.username ? `https://instagram.com/${ig.username}` : '',
highlights: [],
@ -443,7 +470,7 @@ export function mergeEnrichment(
contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정',
profilePhoto: '',
bio: ig.bio || '',
}],
})),
};
// 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;
}

View File

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

View File

@ -14,6 +14,7 @@ interface EnrichRequest {
reportId: string;
clinicName: string;
instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string;
address?: string;
}
@ -47,9 +48,14 @@ Deno.serve(async (req) => {
}
try {
const { reportId, clinicName, instagramHandle, youtubeChannelId, address } =
const { reportId, clinicName, instagramHandle, instagramHandles, youtubeChannelId, address } =
(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");
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
const tasks = [];
// 1. Instagram Profile — with fallback for wrong handle
const cleanIgHandle = normalizeInstagramHandle(instagramHandle);
if (cleanIgHandle) {
// 1. Instagram Profiles — multi-account with fallback
if (igHandlesToTry.length > 0) {
tasks.push(
(async () => {
// Try the given handle first, then common clinic variants
const handleCandidates = [
cleanIgHandle,
`${cleanIgHandle}_ps`, // banobagi → banobagi_ps
`${cleanIgHandle}.ps`, // banobagi → banobagi.ps
`${cleanIgHandle}_clinic`, // banobagi → banobagi_clinic
`${cleanIgHandle}_official`, // banobagi → banobagi_official
const accounts: Record<string, unknown>[] = [];
const triedHandles = new Set<string>();
for (const rawHandle of igHandlesToTry) {
const baseHandle = normalizeInstagramHandle(rawHandle);
if (!baseHandle || triedHandles.has(baseHandle)) continue;
// 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(
"apify~instagram-profile-scraper",
{ usernames: [handle], resultsLimit: 12 },
@ -82,10 +97,8 @@ Deno.serve(async (req) => {
if (profile && !profile.error) {
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)) {
enrichment.instagram = {
accounts.push({
username: profile.username,
followers: profile.followersCount,
following: profile.followsCount,
@ -94,7 +107,7 @@ Deno.serve(async (req) => {
isBusinessAccount: profile.isBusinessAccount,
externalUrl: profile.externalUrl,
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
.slice(0, 12)
.slice(0, 6)
.map((p) => ({
type: p.type,
likes: p.likesCount,
@ -102,11 +115,19 @@ Deno.serve(async (req) => {
caption: (p.caption as string || "").slice(0, 200),
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) {
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
if (YOUTUBE_API_KEY) {

View File

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