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