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
parent
cf482d1bd7
commit
72ea8f4a2d
|
|
@ -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">
|
||||
네이버 블로그, 플레이스, 소셜미디어 종합 분석 리포트 받아보기
|
||||
|
|
|
|||
|
|
@ -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 종합 분석 리포트를 제공합니다.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface EnrichChannelsRequest {
|
|||
reportId: string;
|
||||
clinicName: string;
|
||||
instagramHandle?: string;
|
||||
instagramHandles?: string[];
|
||||
youtubeChannelId?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,55 +64,70 @@ 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 handle of handleCandidates) {
|
||||
const items = await runApifyActor(
|
||||
"apify~instagram-profile-scraper",
|
||||
{ usernames: [handle], resultsLimit: 12 },
|
||||
APIFY_TOKEN
|
||||
);
|
||||
const profile = (items as Record<string, unknown>[])[0];
|
||||
for (const rawHandle of igHandlesToTry) {
|
||||
const baseHandle = normalizeInstagramHandle(rawHandle);
|
||||
if (!baseHandle || triedHandles.has(baseHandle)) continue;
|
||||
|
||||
if (profile && !profile.error) {
|
||||
const followers = (profile.followersCount as number) || 0;
|
||||
// Try the handle + common clinic variants
|
||||
const candidates = [
|
||||
baseHandle,
|
||||
`${baseHandle}_ps`,
|
||||
`${baseHandle}.ps`,
|
||||
`${baseHandle}_clinic`,
|
||||
`${baseHandle}_official`,
|
||||
];
|
||||
|
||||
// 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 = {
|
||||
username: profile.username,
|
||||
followers: profile.followersCount,
|
||||
following: profile.followsCount,
|
||||
posts: profile.postsCount,
|
||||
bio: profile.biography,
|
||||
isBusinessAccount: profile.isBusinessAccount,
|
||||
externalUrl: profile.externalUrl,
|
||||
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
|
||||
.slice(0, 12)
|
||||
.map((p) => ({
|
||||
type: p.type,
|
||||
likes: p.likesCount,
|
||||
comments: p.commentsCount,
|
||||
caption: (p.caption as string || "").slice(0, 200),
|
||||
timestamp: p.timestamp,
|
||||
})),
|
||||
};
|
||||
break; // Found a valid account
|
||||
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 },
|
||||
APIFY_TOKEN
|
||||
);
|
||||
const profile = (items as Record<string, unknown>[])[0];
|
||||
|
||||
if (profile && !profile.error) {
|
||||
const followers = (profile.followersCount as number) || 0;
|
||||
if (followers >= 100 || ((profile.isBusinessAccount as boolean) && (profile.postsCount as number) > 10)) {
|
||||
accounts.push({
|
||||
username: profile.username,
|
||||
followers: profile.followersCount,
|
||||
following: profile.followsCount,
|
||||
posts: profile.postsCount,
|
||||
bio: profile.biography,
|
||||
isBusinessAccount: profile.isBusinessAccount,
|
||||
externalUrl: profile.externalUrl,
|
||||
latestPosts: ((profile.latestPosts as Record<string, unknown>[]) || [])
|
||||
.slice(0, 6)
|
||||
.map((p) => ({
|
||||
type: p.type,
|
||||
likes: p.likesCount,
|
||||
comments: p.commentsCount,
|
||||
caption: (p.caption as string || "").slice(0, 200),
|
||||
timestamp: p.timestamp,
|
||||
})),
|
||||
});
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue