fix: add Authorization header to all Edge Function calls + fix Vision Analysis
- All fetch calls to Supabase Edge Functions now include Authorization: Bearer <anon_key> (was missing → 401 errors) - Fix Firecrawl screenshot API: remove invalid screenshotOptions, use "screenshot@fullPage" format (v2 API compatibility) - Fix screenshot response handling: v2 returns URL not base64, now downloads and converts to base64 for Gemini Vision - Add about page to Vision Analysis capture targets - Add retry utility, channel error tracking, pipeline resume, enrichment retry, EmptyState improvements (Sprint 2-3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>claude/bold-hawking
parent
2f2aa5a5b6
commit
79950925a1
|
|
@ -5,7 +5,7 @@ import PageNavigator from './components/PageNavigator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoadingPage = location.pathname === '/report/loading';
|
const isLoadingPage = location.pathname.startsWith('/report/loading');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,83 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, AlertCircle, Info, RefreshCw } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type EmptyStatus = 'loading' | 'error' | 'not_found' | 'timeout';
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
subtext?: string;
|
subtext?: string;
|
||||||
|
status?: EmptyStatus;
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** Auto-timeout: switch to 'timeout' status after N seconds (default: 60) */
|
||||||
|
autoTimeoutSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<EmptyStatus, {
|
||||||
|
icon: typeof Search;
|
||||||
|
iconColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
defaultMessage: string;
|
||||||
|
defaultSubtext: string;
|
||||||
|
}> = {
|
||||||
|
loading: {
|
||||||
|
icon: Search,
|
||||||
|
iconColor: 'text-slate-400',
|
||||||
|
bgColor: 'bg-slate-100',
|
||||||
|
defaultMessage: '데이터 수집 중',
|
||||||
|
defaultSubtext: '채널 데이터 보강이 완료되면 자동으로 업데이트됩니다.',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconColor: 'text-red-400',
|
||||||
|
bgColor: 'bg-red-50',
|
||||||
|
defaultMessage: '데이터 수집 실패',
|
||||||
|
defaultSubtext: '일시적인 오류가 발생했습니다. 다시 시도해 주세요.',
|
||||||
|
},
|
||||||
|
not_found: {
|
||||||
|
icon: Info,
|
||||||
|
iconColor: 'text-blue-400',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
defaultMessage: '채널을 찾을 수 없음',
|
||||||
|
defaultSubtext: '이 채널은 발견되지 않았거나 데이터가 없습니다.',
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconColor: 'text-amber-400',
|
||||||
|
bgColor: 'bg-amber-50',
|
||||||
|
defaultMessage: '응답 시간 초과',
|
||||||
|
defaultSubtext: '데이터 수집에 시간이 오래 걸리고 있습니다.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shown inside report sections when data is not yet available
|
* Shown inside report sections when data is not yet available
|
||||||
* (e.g., before enrichment completes or when a channel is not found).
|
* (e.g., before enrichment completes, when a channel is not found, or on error).
|
||||||
*/
|
*/
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
message = '데이터 수집 중',
|
message,
|
||||||
subtext = '채널 데이터 보강이 완료되면 자동으로 업데이트됩니다.',
|
subtext,
|
||||||
|
status = 'loading',
|
||||||
|
onRetry,
|
||||||
|
autoTimeoutSec = 60,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
|
const [currentStatus, setCurrentStatus] = useState<EmptyStatus>(status);
|
||||||
|
|
||||||
|
// Auto-timeout: switch from 'loading' to 'timeout' after N seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'loading') return;
|
||||||
|
const timer = setTimeout(() => setCurrentStatus('timeout'), autoTimeoutSec * 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [status, autoTimeoutSec]);
|
||||||
|
|
||||||
|
// Sync external status changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentStatus(status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const config = STATUS_CONFIG[currentStatus];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex flex-col items-center justify-center py-12 text-center"
|
className="flex flex-col items-center justify-center py-12 text-center"
|
||||||
|
|
@ -21,11 +85,25 @@ export function EmptyState({
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
<div className="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
|
<div className={`w-12 h-12 rounded-2xl ${config.bgColor} flex items-center justify-center mb-4`}>
|
||||||
<Search size={20} className="text-slate-400" />
|
{currentStatus === 'loading' ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon size={20} className={config.iconColor} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-slate-500">{message}</p>
|
<p className="text-sm font-medium text-slate-500">{message || config.defaultMessage}</p>
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-xs">{subtext}</p>
|
<p className="text-xs text-slate-400 mt-1 max-w-xs">{subtext || config.defaultSubtext}</p>
|
||||||
|
|
||||||
|
{onRetry && (currentStatus === 'error' || currentStatus === 'timeout') && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-4 flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ type EnrichmentStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
interface UseEnrichmentResult {
|
interface UseEnrichmentResult {
|
||||||
status: EnrichmentStatus;
|
status: EnrichmentStatus;
|
||||||
enrichedReport: MarketingReport | null;
|
enrichedReport: MarketingReport | null;
|
||||||
|
/** Number of retry attempts made */
|
||||||
|
retryCount: number;
|
||||||
|
/** Call this to retry enrichment (max 2 retries) */
|
||||||
|
retry: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnrichmentParams {
|
interface EnrichmentParams {
|
||||||
|
|
@ -20,10 +24,12 @@ interface EnrichmentParams {
|
||||||
address?: string;
|
address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers background channel enrichment after Phase 1 report renders.
|
* Triggers background channel enrichment after Phase 1 report renders.
|
||||||
* Fires once, waits for the Edge Function to complete (~27s),
|
* Fires once, waits for the Edge Function to complete (~27s),
|
||||||
* then returns the merged report.
|
* then returns the merged report. Supports up to 2 manual retries.
|
||||||
*/
|
*/
|
||||||
export function useEnrichment(
|
export function useEnrichment(
|
||||||
baseReport: MarketingReport | null,
|
baseReport: MarketingReport | null,
|
||||||
|
|
@ -31,40 +37,55 @@ export function useEnrichment(
|
||||||
): UseEnrichmentResult {
|
): UseEnrichmentResult {
|
||||||
const [status, setStatus] = useState<EnrichmentStatus>('idle');
|
const [status, setStatus] = useState<EnrichmentStatus>('idle');
|
||||||
const [enrichedReport, setEnrichedReport] = useState<MarketingReport | null>(null);
|
const [enrichedReport, setEnrichedReport] = useState<MarketingReport | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const hasTriggered = useRef(false);
|
const hasTriggered = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const doEnrich = useCallback(async () => {
|
||||||
if (!baseReport || !params?.reportId || hasTriggered.current) return;
|
if (!baseReport || !params?.reportId) return;
|
||||||
// Always enrich if clinicName exists — Naver, 강남언니, Google Maps work with name alone
|
|
||||||
|
|
||||||
hasTriggered.current = true;
|
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
|
||||||
enrichChannels({
|
try {
|
||||||
reportId: params.reportId,
|
const result = await enrichChannels({
|
||||||
clinicName: params.clinicName,
|
reportId: params.reportId,
|
||||||
instagramHandle: params.instagramHandle,
|
clinicName: params.clinicName,
|
||||||
instagramHandles: params.instagramHandles,
|
instagramHandle: params.instagramHandle,
|
||||||
youtubeChannelId: params.youtubeChannelId,
|
instagramHandles: params.instagramHandles,
|
||||||
facebookHandle: params.facebookHandle,
|
youtubeChannelId: params.youtubeChannelId,
|
||||||
address: params.address,
|
facebookHandle: params.facebookHandle,
|
||||||
})
|
address: params.address,
|
||||||
.then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const merged = mergeEnrichment(baseReport, result.data as EnrichmentData);
|
|
||||||
setEnrichedReport(merged);
|
|
||||||
setStatus('success');
|
|
||||||
} else {
|
|
||||||
setStatus('error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setStatus('error');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const merged = mergeEnrichment(baseReport, result.data as EnrichmentData);
|
||||||
|
setEnrichedReport(merged);
|
||||||
|
setStatus('success');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
}, [baseReport, params]);
|
}, [baseReport, params]);
|
||||||
|
|
||||||
|
// Initial trigger
|
||||||
|
useEffect(() => {
|
||||||
|
if (!baseReport || !params?.reportId || hasTriggered.current) return;
|
||||||
|
hasTriggered.current = true;
|
||||||
|
doEnrich();
|
||||||
|
}, [baseReport, params, doEnrich]);
|
||||||
|
|
||||||
|
// Manual retry
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
if (retryCount >= MAX_RETRIES) return;
|
||||||
|
setRetryCount(prev => prev + 1);
|
||||||
|
doEnrich();
|
||||||
|
}, [retryCount, doEnrich]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
enrichedReport,
|
enrichedReport,
|
||||||
|
retryCount,
|
||||||
|
retry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,18 @@ const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
|
||||||
|
/** Common headers for Edge Function calls (includes JWT auth) */
|
||||||
|
const fnHeaders = () => ({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${supabaseAnonKey}`,
|
||||||
|
});
|
||||||
|
|
||||||
export async function generateMarketingReport(url: string, clinicName?: string) {
|
export async function generateMarketingReport(url: string, clinicName?: string) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${supabaseUrl}/functions/v1/generate-report`,
|
`${supabaseUrl}/functions/v1/generate-report`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify({ url, clinicName }),
|
body: JSON.stringify({ url, clinicName }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -33,6 +39,38 @@ export async function fetchReportById(reportId: string) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch pipeline status for a report.
|
||||||
|
* Used by AnalysisLoadingPage to resume interrupted pipelines.
|
||||||
|
*/
|
||||||
|
export interface PipelineStatus {
|
||||||
|
reportId: string;
|
||||||
|
clinicId?: string;
|
||||||
|
runId?: string;
|
||||||
|
status: string;
|
||||||
|
clinicName?: string;
|
||||||
|
hasChannelData: boolean;
|
||||||
|
hasReport: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPipelineStatus(reportId: string): Promise<PipelineStatus> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("marketing_reports")
|
||||||
|
.select("id, status, clinic_name, channel_data, report")
|
||||||
|
.eq("id", reportId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) throw new Error(`Report not found: ${error?.message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportId: data.id,
|
||||||
|
status: data.status || "unknown",
|
||||||
|
clinicName: data.clinic_name,
|
||||||
|
hasChannelData: !!data.channel_data && Object.keys(data.channel_data).length > 0,
|
||||||
|
hasReport: !!data.report && Object.keys(data.report).length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnrichChannelsRequest {
|
export interface EnrichChannelsRequest {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
clinicName: string;
|
clinicName: string;
|
||||||
|
|
@ -52,7 +90,7 @@ export async function enrichChannels(params: EnrichChannelsRequest) {
|
||||||
`${supabaseUrl}/functions/v1/enrich-channels`,
|
`${supabaseUrl}/functions/v1/enrich-channels`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -69,7 +107,7 @@ export async function scrapeWebsite(url: string, clinicName?: string) {
|
||||||
`${supabaseUrl}/functions/v1/scrape-website`,
|
`${supabaseUrl}/functions/v1/scrape-website`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify({ url, clinicName }),
|
body: JSON.stringify({ url, clinicName }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -92,7 +130,7 @@ export async function discoverChannels(url: string, clinicName?: string) {
|
||||||
`${supabaseUrl}/functions/v1/discover-channels`,
|
`${supabaseUrl}/functions/v1/discover-channels`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify({ url, clinicName }),
|
body: JSON.stringify({ url, clinicName }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -113,7 +151,7 @@ export async function collectChannelData(reportId: string, clinicId?: string, ru
|
||||||
`${supabaseUrl}/functions/v1/collect-channel-data`,
|
`${supabaseUrl}/functions/v1/collect-channel-data`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify({ reportId, clinicId, runId }),
|
body: JSON.stringify({ reportId, clinicId, runId }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -133,7 +171,7 @@ export async function generateReportV2(reportId: string, clinicId?: string, runI
|
||||||
`${supabaseUrl}/functions/v1/generate-report`,
|
`${supabaseUrl}/functions/v1/generate-report`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: fnHeaders(),
|
||||||
body: JSON.stringify({ reportId, clinicId, runId }),
|
body: JSON.stringify({ reportId, clinicId, runId }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ createRoot(document.getElementById('root')!).render(
|
||||||
<Route element={<App />}>
|
<Route element={<App />}>
|
||||||
<Route index element={<LandingPage />} />
|
<Route index element={<LandingPage />} />
|
||||||
<Route path="report/loading" element={<AnalysisLoadingPage />} />
|
<Route path="report/loading" element={<AnalysisLoadingPage />} />
|
||||||
|
<Route path="report/loading/:reportId" element={<AnalysisLoadingPage />} />
|
||||||
<Route path="report/:id" element={<ReportPage />} />
|
<Route path="report/:id" element={<ReportPage />} />
|
||||||
<Route path="plan/:id" element={<MarketingPlanPage />} />
|
<Route path="plan/:id" element={<MarketingPlanPage />} />
|
||||||
<Route path="studio/:id" element={<ContentStudioPage />} />
|
<Route path="studio/:id" element={<ContentStudioPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Check, AlertCircle } from 'lucide-react';
|
import { Check, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { discoverChannels, collectChannelData, generateReportV2 } from '../lib/supabase';
|
import {
|
||||||
|
discoverChannels,
|
||||||
|
collectChannelData,
|
||||||
|
generateReportV2,
|
||||||
|
fetchPipelineStatus,
|
||||||
|
} from '../lib/supabase';
|
||||||
|
|
||||||
type Phase = 'discovering' | 'collecting' | 'generating' | 'complete';
|
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'complete';
|
||||||
|
|
||||||
const PHASE_STEPS = [
|
const PHASE_STEPS = [
|
||||||
{ key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
|
{ key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
|
||||||
|
|
@ -13,47 +18,95 @@ const PHASE_STEPS = [
|
||||||
{ key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' },
|
{ key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Session keys for pipeline resume
|
||||||
|
const SESSION_KEYS = {
|
||||||
|
reportId: 'infinith_reportId',
|
||||||
|
clinicId: 'infinith_clinicId',
|
||||||
|
runId: 'infinith_runId',
|
||||||
|
url: 'infinith_url',
|
||||||
|
};
|
||||||
|
|
||||||
|
function saveSession(data: { reportId: string; clinicId?: string; runId?: string; url?: string }) {
|
||||||
|
sessionStorage.setItem(SESSION_KEYS.reportId, data.reportId);
|
||||||
|
if (data.clinicId) sessionStorage.setItem(SESSION_KEYS.clinicId, data.clinicId);
|
||||||
|
if (data.runId) sessionStorage.setItem(SESSION_KEYS.runId, data.runId);
|
||||||
|
if (data.url) sessionStorage.setItem(SESSION_KEYS.url, data.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSession() {
|
||||||
|
return {
|
||||||
|
reportId: sessionStorage.getItem(SESSION_KEYS.reportId),
|
||||||
|
clinicId: sessionStorage.getItem(SESSION_KEYS.clinicId),
|
||||||
|
runId: sessionStorage.getItem(SESSION_KEYS.runId),
|
||||||
|
url: sessionStorage.getItem(SESSION_KEYS.url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
Object.values(SESSION_KEYS).forEach(k => sessionStorage.removeItem(k));
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalysisLoadingPage() {
|
export default function AnalysisLoadingPage() {
|
||||||
const [phase, setPhase] = useState<Phase>('discovering');
|
const [phase, setPhase] = useState<Phase>('discovering');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { reportId: urlReportId } = useParams<{ reportId?: string }>();
|
||||||
const url = (location.state as { url?: string })?.url;
|
const url = (location.state as { url?: string })?.url;
|
||||||
const hasStarted = useRef(false);
|
const hasStarted = useRef(false);
|
||||||
|
|
||||||
const phaseIndex = PHASE_STEPS.findIndex(s => s.key === phase);
|
const phaseIndex = PHASE_STEPS.findIndex(s => s.key === phase);
|
||||||
|
|
||||||
useEffect(() => {
|
const runPipeline = useCallback(async (
|
||||||
if (hasStarted.current) return;
|
startUrl?: string,
|
||||||
hasStarted.current = true;
|
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let reportId = resumeFrom?.reportId || '';
|
||||||
|
let clinicId = resumeFrom?.clinicId;
|
||||||
|
let runId = resumeFrom?.runId;
|
||||||
|
let startPhase = resumeFrom?.phase || 'discovering';
|
||||||
|
|
||||||
if (!url) {
|
// Phase 1: Discover Channels (skip if resuming from later phase)
|
||||||
navigate('/', { replace: true });
|
if (startPhase === 'discovering') {
|
||||||
return;
|
if (!startUrl) throw new Error('No URL provided');
|
||||||
}
|
|
||||||
|
|
||||||
const runPipeline = async () => {
|
|
||||||
try {
|
|
||||||
// Phase 1: Discover Channels
|
|
||||||
setPhase('discovering');
|
setPhase('discovering');
|
||||||
const discovery = await discoverChannels(url);
|
const discovery = await discoverChannels(startUrl);
|
||||||
if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed');
|
if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed');
|
||||||
const reportId = discovery.reportId;
|
reportId = discovery.reportId;
|
||||||
const clinicId = discovery.clinicId; // V3
|
clinicId = discovery.clinicId;
|
||||||
const runId = discovery.runId; // V3
|
runId = discovery.runId;
|
||||||
|
|
||||||
// Phase 2: Collect Channel Data
|
// Save to session + update URL for resume
|
||||||
|
saveSession({ reportId, clinicId, runId, url: startUrl });
|
||||||
|
window.history.replaceState(null, '', `/report/loading/${reportId}`);
|
||||||
|
startPhase = 'collecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Collect Channel Data
|
||||||
|
if (startPhase === 'collecting') {
|
||||||
setPhase('collecting');
|
setPhase('collecting');
|
||||||
const collection = await collectChannelData(reportId, clinicId, runId);
|
const collection = await collectChannelData(reportId, clinicId, runId);
|
||||||
if (!collection.success) throw new Error(collection.error || 'Data collection failed');
|
// Allow partial success — only fail on total failure
|
||||||
|
if (collection.success === false && !collection.partialFailure) {
|
||||||
|
throw new Error(collection.error || 'Data collection failed');
|
||||||
|
}
|
||||||
|
if (collection.channelErrors && Object.keys(collection.channelErrors).length > 0) {
|
||||||
|
console.warn('[pipeline] Partial failures:', collection.channelErrors);
|
||||||
|
}
|
||||||
|
startPhase = 'generating';
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: Generate Report
|
// Phase 3: Generate Report
|
||||||
|
if (startPhase === 'generating') {
|
||||||
setPhase('generating');
|
setPhase('generating');
|
||||||
const result = await generateReportV2(reportId, clinicId, runId);
|
const result = await generateReportV2(reportId, clinicId, runId);
|
||||||
if (!result.success) throw new Error(result.error || 'Report generation failed');
|
if (!result.success) throw new Error(result.error || 'Report generation failed');
|
||||||
|
|
||||||
// Complete — navigate to report
|
// Complete — navigate to report
|
||||||
setPhase('complete');
|
setPhase('complete');
|
||||||
|
clearSession();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(`/report/${reportId}`, {
|
navigate(`/report/${reportId}`, {
|
||||||
|
|
@ -63,13 +116,117 @@ export default function AnalysisLoadingPage() {
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}, 800);
|
}, 800);
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
||||||
}
|
}
|
||||||
};
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
runPipeline();
|
// Retry from the current failed phase
|
||||||
}, [url, navigate]);
|
const handleRetry = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
setErrorDetails(null);
|
||||||
|
const session = loadSession();
|
||||||
|
if (session.reportId) {
|
||||||
|
// Resume from the phase that failed
|
||||||
|
runPipeline(undefined, {
|
||||||
|
reportId: session.reportId,
|
||||||
|
clinicId: session.clinicId || undefined,
|
||||||
|
runId: session.runId || undefined,
|
||||||
|
phase,
|
||||||
|
});
|
||||||
|
} else if (url || session.url) {
|
||||||
|
// Restart from scratch
|
||||||
|
hasStarted.current = false;
|
||||||
|
runPipeline(url || session.url || undefined);
|
||||||
|
}
|
||||||
|
}, [phase, url, runPipeline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStarted.current) return;
|
||||||
|
hasStarted.current = true;
|
||||||
|
|
||||||
|
// 1. Try URL param resume (e.g., /report/loading/abc-123)
|
||||||
|
if (urlReportId) {
|
||||||
|
setPhase('resuming');
|
||||||
|
fetchPipelineStatus(urlReportId)
|
||||||
|
.then((status) => {
|
||||||
|
// Also check sessionStorage for clinicId/runId
|
||||||
|
const session = loadSession();
|
||||||
|
const clinicId = session.clinicId || status.clinicId;
|
||||||
|
const runId = session.runId || status.runId;
|
||||||
|
|
||||||
|
if (status.hasReport || status.status === 'complete') {
|
||||||
|
// Already done — go to report
|
||||||
|
navigate(`/report/${urlReportId}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resumePhase: Phase = 'discovering';
|
||||||
|
if (status.status === 'discovered' || status.status === 'discovering') {
|
||||||
|
resumePhase = 'collecting';
|
||||||
|
} else if (['collecting', 'collected', 'partial'].includes(status.status)) {
|
||||||
|
resumePhase = 'generating';
|
||||||
|
} else if (status.status === 'collection_failed') {
|
||||||
|
setError('Data collection failed. Please retry.');
|
||||||
|
setPhase('collecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSession({ reportId: urlReportId, clinicId, runId, url: session.url || undefined });
|
||||||
|
runPipeline(undefined, { reportId: urlReportId, clinicId, runId, phase: resumePhase });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Could not resume analysis. Please try again.');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try sessionStorage resume
|
||||||
|
const session = loadSession();
|
||||||
|
if (session.reportId && !url) {
|
||||||
|
setPhase('resuming');
|
||||||
|
fetchPipelineStatus(session.reportId)
|
||||||
|
.then((status) => {
|
||||||
|
if (status.hasReport || status.status === 'complete') {
|
||||||
|
clearSession();
|
||||||
|
navigate(`/report/${session.reportId}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resumePhase: Phase = 'discovering';
|
||||||
|
if (['discovered', 'discovering'].includes(status.status)) {
|
||||||
|
resumePhase = 'collecting';
|
||||||
|
} else if (['collecting', 'collected', 'partial'].includes(status.status)) {
|
||||||
|
resumePhase = 'generating';
|
||||||
|
}
|
||||||
|
|
||||||
|
runPipeline(undefined, {
|
||||||
|
reportId: session.reportId!,
|
||||||
|
clinicId: session.clinicId || undefined,
|
||||||
|
runId: session.runId || undefined,
|
||||||
|
phase: resumePhase,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearSession();
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fresh start with URL
|
||||||
|
if (!url) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runPipeline(url);
|
||||||
|
}, [url, urlReportId, navigate, runPipeline]);
|
||||||
|
|
||||||
|
// Adjust phaseIndex for 'resuming' state
|
||||||
|
const displayPhaseIndex = phase === 'resuming' ? -1 : phaseIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
|
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
|
||||||
|
|
@ -91,14 +248,14 @@ export default function AnalysisLoadingPage() {
|
||||||
INFINITH
|
INFINITH
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{url && (
|
{(url || loadSession().url) && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.1 }}
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
|
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
|
||||||
>
|
>
|
||||||
{url}
|
{url || loadSession().url}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -110,68 +267,102 @@ export default function AnalysisLoadingPage() {
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||||
<p className="text-red-300 text-sm mb-4">{error}</p>
|
<p className="text-red-300 text-sm mb-4">{error}</p>
|
||||||
<button
|
|
||||||
onClick={() => navigate('/', { replace: true })}
|
{errorDetails && (
|
||||||
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
<div className="mb-4 text-left bg-red-500/5 rounded-lg p-3">
|
||||||
>
|
<p className="text-red-400/60 text-xs font-mono mb-1">Failed channels:</p>
|
||||||
Try Again
|
{Object.entries(errorDetails).map(([ch, err]) => (
|
||||||
</button>
|
<p key={ch} className="text-red-400/80 text-xs font-mono">
|
||||||
|
• {ch}: {err}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
Start Over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="w-full space-y-5 mb-14">
|
{phase === 'resuming' ? (
|
||||||
{PHASE_STEPS.map((step, index) => {
|
|
||||||
const isCompleted = phaseIndex > index || (step.key === 'complete' && phase === 'complete');
|
|
||||||
const isActive = phaseIndex === index && phase !== 'complete';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={step.key}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: index * 0.15 }}
|
|
||||||
className="flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
|
||||||
{isCompleted ? (
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
||||||
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
|
||||||
</motion.div>
|
|
||||||
) : isActive ? (
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
|
||||||
) : (
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-base font-sans transition-colors duration-300 ${
|
|
||||||
isCompleted ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isCompleted ? step.labelDone : step.label}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: '0%' }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ width: `${((phaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
className="flex flex-col items-center gap-4 mb-14"
|
||||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
>
|
||||||
/>
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
</div>
|
<p className="text-purple-200 text-sm">Resuming analysis...</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-full space-y-5 mb-14">
|
||||||
|
{PHASE_STEPS.map((step, index) => {
|
||||||
|
const isCompleted = displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
|
||||||
|
const isActive = displayPhaseIndex === index && phase !== 'complete';
|
||||||
|
|
||||||
<p className="text-white/40 text-xs mt-4">
|
return (
|
||||||
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
|
<motion.div
|
||||||
</p>
|
key={step.key}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: index * 0.15 }}
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{isCompleted ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
) : isActive ? (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-base font-sans transition-colors duration-300 ${
|
||||||
|
isCompleted ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted ? step.labelDone : step.label}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: `${((displayPhaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||||
|
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/40 text-xs mt-4">
|
||||||
|
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/**
|
||||||
|
* Retry utility for external API calls.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Exponential backoff with jitter
|
||||||
|
* - Respects Retry-After header (429)
|
||||||
|
* - Permanent failure detection (400/401/403/404 → no retry)
|
||||||
|
* - Per-request timeout via AbortController
|
||||||
|
* - Domain-level rate limiting (e.g., Firecrawl 500ms gap)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Types ───
|
||||||
|
|
||||||
|
export interface RetryOptions {
|
||||||
|
/** Max number of retries (default: 2) */
|
||||||
|
maxRetries?: number;
|
||||||
|
/** Backoff delays in ms per attempt (default: [1000, 3000]) */
|
||||||
|
backoffMs?: number[];
|
||||||
|
/** HTTP status codes to retry on (default: [429, 500, 502, 503]) */
|
||||||
|
retryOn?: number[];
|
||||||
|
/** Per-request timeout in ms (default: 45000) */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Label for logging */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetryResult {
|
||||||
|
response: Response;
|
||||||
|
attempts: number;
|
||||||
|
retried: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Domain Rate Limiter ───
|
||||||
|
|
||||||
|
const domainLastCall = new Map<string, number>();
|
||||||
|
const DOMAIN_INTERVALS: Record<string, number> = {
|
||||||
|
"api.firecrawl.dev": 500,
|
||||||
|
"api.perplexity.ai": 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDomainSlot(url: string): Promise<void> {
|
||||||
|
const domain = getDomain(url);
|
||||||
|
const interval = DOMAIN_INTERVALS[domain];
|
||||||
|
if (!interval) return;
|
||||||
|
|
||||||
|
const last = domainLastCall.get(domain) || 0;
|
||||||
|
const elapsed = Date.now() - last;
|
||||||
|
if (elapsed < interval) {
|
||||||
|
await new Promise((r) => setTimeout(r, interval - elapsed));
|
||||||
|
}
|
||||||
|
domainLastCall.set(domain, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Permanent failure codes (never retry) ───
|
||||||
|
|
||||||
|
const PERMANENT_FAILURES = new Set([400, 401, 403, 404, 405, 409, 422]);
|
||||||
|
|
||||||
|
// ─── Main Function ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch() with automatic retry, timeout, and rate limiting.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const res = await fetchWithRetry("https://api.example.com/data", {
|
||||||
|
* method: "POST",
|
||||||
|
* headers: { "Content-Type": "application/json" },
|
||||||
|
* body: JSON.stringify({ query: "test" }),
|
||||||
|
* }, { maxRetries: 2, timeoutMs: 30000, label: "example-api" });
|
||||||
|
*/
|
||||||
|
export async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
opts?: RetryOptions,
|
||||||
|
): Promise<Response> {
|
||||||
|
const {
|
||||||
|
maxRetries = 2,
|
||||||
|
backoffMs = [1000, 3000],
|
||||||
|
retryOn = [429, 500, 502, 503],
|
||||||
|
timeoutMs = 45000,
|
||||||
|
label = getDomain(url),
|
||||||
|
} = opts || {};
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
const retrySet = new Set(retryOn);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
// Rate limit between domain calls
|
||||||
|
await waitForDomainSlot(url);
|
||||||
|
|
||||||
|
// AbortController for per-request timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
// Success
|
||||||
|
if (response.ok) return response;
|
||||||
|
|
||||||
|
// Permanent failure — don't retry
|
||||||
|
if (PERMANENT_FAILURES.has(response.status)) {
|
||||||
|
console.warn(
|
||||||
|
`[retry:${label}] Permanent failure ${response.status} on attempt ${attempt + 1}`,
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retryable failure
|
||||||
|
if (retrySet.has(response.status) && attempt < maxRetries) {
|
||||||
|
let delay = backoffMs[attempt] || backoffMs[backoffMs.length - 1] || 3000;
|
||||||
|
|
||||||
|
// Respect Retry-After header for 429
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = response.headers.get("Retry-After");
|
||||||
|
if (retryAfter) {
|
||||||
|
const parsed = parseInt(retryAfter, 10);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
delay = Math.max(delay, parsed * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add jitter (±20%)
|
||||||
|
delay = delay * (0.8 + Math.random() * 0.4);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[retry:${label}] Status ${response.status}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retryable or exhausted retries
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = (backoffMs[attempt] || 3000) * (0.8 + Math.random() * 0.4);
|
||||||
|
console.warn(
|
||||||
|
`[retry:${label}] Network error: ${lastError.message}, retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error(`[retry:${label}] All ${maxRetries + 1} attempts failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Convenience: JSON fetch with retry ───
|
||||||
|
|
||||||
|
export async function fetchJsonWithRetry<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
opts?: RetryOptions,
|
||||||
|
): Promise<{ data: T | null; status: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithRetry(url, init, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
return { data: null, status: res.status, error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as T;
|
||||||
|
return { data, status: res.status };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { data: null, status: 0, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel Task Wrapper ───
|
||||||
|
|
||||||
|
export interface ChannelTaskResult {
|
||||||
|
channel: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
httpStatus?: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a channel collection task with timing and error capture.
|
||||||
|
* Used by collect-channel-data to track per-channel success/failure.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [result, taskMeta] = await wrapChannelTask("instagram", async () => {
|
||||||
|
* // ... collect instagram data ...
|
||||||
|
* channelData.instagram = data;
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export async function wrapChannelTask(
|
||||||
|
channel: string,
|
||||||
|
task: () => Promise<void>,
|
||||||
|
): Promise<ChannelTaskResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
return { channel, success: true, durationMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[channel:${channel}] Error: ${msg}`);
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
success: false,
|
||||||
|
error: msg,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
export interface VerifiedChannel {
|
export interface VerifiedChannel {
|
||||||
handle: string;
|
handle: string;
|
||||||
verified: boolean;
|
verified: boolean | "unverifiable";
|
||||||
url?: string;
|
url?: string;
|
||||||
channelId?: string; // YouTube channel ID if resolved
|
channelId?: string; // YouTube channel ID if resolved
|
||||||
}
|
}
|
||||||
|
|
@ -28,17 +28,36 @@ async function verifyInstagram(handle: string): Promise<VerifiedChannel> {
|
||||||
const url = `https://www.instagram.com/${handle}/`;
|
const url = `https://www.instagram.com/${handle}/`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
|
||||||
redirect: 'follow',
|
redirect: 'manual', // Don't follow redirects — detect login page
|
||||||
});
|
});
|
||||||
// Instagram returns 200 for existing profiles, 404 for missing
|
|
||||||
return {
|
// 302/301 to login page → Instagram blocks unauthenticated access
|
||||||
handle,
|
if (res.status === 301 || res.status === 302) {
|
||||||
verified: res.status === 200,
|
const location = res.headers.get('location') || '';
|
||||||
url,
|
if (location.includes('/accounts/login') || location.includes('/challenge')) {
|
||||||
};
|
return { handle, verified: 'unverifiable', url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 → profile exists; 404 → definitely not found
|
||||||
|
if (res.status === 200) {
|
||||||
|
// Double-check: some 200 responses are actually the login page
|
||||||
|
const bodySnippet = await res.text().then(t => t.slice(0, 2000)).catch(() => '');
|
||||||
|
if (bodySnippet.includes('/accounts/login') && !bodySnippet.includes(`"username":"${handle}"`)) {
|
||||||
|
return { handle, verified: 'unverifiable', url };
|
||||||
|
}
|
||||||
|
return { handle, verified: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return { handle, verified: false, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other status → unverifiable (don't assume it doesn't exist)
|
||||||
|
return { handle, verified: 'unverifiable', url };
|
||||||
} catch {
|
} catch {
|
||||||
return { handle, verified: false };
|
return { handle, verified: 'unverifiable' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,14 +105,30 @@ async function verifyYouTube(handle: string, apiKey: string): Promise<VerifiedCh
|
||||||
async function verifyFacebook(handle: string): Promise<VerifiedChannel> {
|
async function verifyFacebook(handle: string): Promise<VerifiedChannel> {
|
||||||
try {
|
try {
|
||||||
const url = `https://www.facebook.com/${handle}/`;
|
const url = `https://www.facebook.com/${handle}/`;
|
||||||
|
// Use GET instead of HEAD — Facebook blocks HEAD requests
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'HEAD',
|
method: 'GET',
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
});
|
});
|
||||||
return { handle, verified: res.status === 200, url };
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
// Check if it's a real page or a redirect to login/error
|
||||||
|
const bodySnippet = await res.text().then(t => t.slice(0, 3000)).catch(() => '');
|
||||||
|
if (bodySnippet.includes('page_not_found') || bodySnippet.includes('This content isn')) {
|
||||||
|
return { handle, verified: false, url };
|
||||||
|
}
|
||||||
|
return { handle, verified: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return { handle, verified: false, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facebook often blocks bots → unverifiable, not false
|
||||||
|
return { handle, verified: 'unverifiable', url };
|
||||||
} catch {
|
} catch {
|
||||||
return { handle, verified: false };
|
return { handle, verified: 'unverifiable' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
* certifications, social icons, brand colors, etc.).
|
* certifications, social icons, brand colors, etc.).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { fetchWithRetry } from "./retry.ts";
|
||||||
|
|
||||||
const FIRECRAWL_BASE = "https://api.firecrawl.dev/v1";
|
const FIRECRAWL_BASE = "https://api.firecrawl.dev/v1";
|
||||||
|
|
||||||
export interface ScreenshotResult {
|
export interface ScreenshotResult {
|
||||||
|
|
@ -34,15 +36,20 @@ export interface VisionAnalysisResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture screenshot of a URL via Firecrawl.
|
* Capture screenshot of a URL via Firecrawl v2.
|
||||||
* Returns base64 image data.
|
* Returns { screenshotUrl, base64 } — URL from Firecrawl, base64 fetched for Vision analysis.
|
||||||
|
*
|
||||||
|
* Firecrawl v2 returns a GCS URL (not base64). We download it and convert to base64
|
||||||
|
* so Gemini Vision can consume it via inlineData.
|
||||||
*/
|
*/
|
||||||
async function captureScreenshot(
|
async function captureScreenshot(
|
||||||
url: string,
|
url: string,
|
||||||
firecrawlKey: string,
|
firecrawlKey: string,
|
||||||
): Promise<string | null> {
|
): Promise<{ screenshotUrl: string; base64: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${FIRECRAWL_BASE}/scrape`, {
|
console.log(`[vision] Capturing screenshot: ${url}`);
|
||||||
|
// Firecrawl v2: use "screenshot@fullPage" format (no separate screenshotOptions)
|
||||||
|
const res = await fetchWithRetry(`${FIRECRAWL_BASE}/scrape`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -50,18 +57,47 @@ async function captureScreenshot(
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url,
|
url,
|
||||||
formats: ["screenshot"],
|
formats: ["screenshot@fullPage"],
|
||||||
waitFor: 5000,
|
waitFor: 5000,
|
||||||
screenshotOptions: {
|
|
||||||
fullPage: false,
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
}, { label: `firecrawl-screenshot`, timeoutMs: 45000, maxRetries: 1 });
|
||||||
if (!res.ok) return null;
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[vision] Screenshot failed for ${url}: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.data?.screenshot || null; // base64 string
|
const screenshotUrl: string | null = data.data?.screenshot || null;
|
||||||
} catch {
|
if (!screenshotUrl) {
|
||||||
|
console.warn(`[vision] Screenshot response OK but no screenshot URL for ${url}. Keys: ${JSON.stringify(Object.keys(data.data || {}))}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[vision] Screenshot URL received: ${url} → ${screenshotUrl.slice(0, 80)}...`);
|
||||||
|
|
||||||
|
// Download the screenshot image and convert to base64 for Gemini Vision
|
||||||
|
const imgRes = await fetchWithRetry(screenshotUrl, undefined, {
|
||||||
|
label: `screenshot-download`,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
maxRetries: 1,
|
||||||
|
});
|
||||||
|
if (!imgRes.ok) {
|
||||||
|
console.error(`[vision] Failed to download screenshot image: HTTP ${imgRes.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const imgBuffer = await imgRes.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(imgBuffer);
|
||||||
|
|
||||||
|
// Use Deno's standard base64 encoding (efficient for large binaries)
|
||||||
|
const { encode: encodeBase64 } = await import("https://deno.land/std@0.224.0/encoding/base64.ts");
|
||||||
|
const base64 = encodeBase64(bytes);
|
||||||
|
|
||||||
|
console.log(`[vision] Screenshot captured & converted: ${url} (${Math.round(base64.length / 1024)}KB base64, ${Math.round(bytes.length / 1024)}KB raw)`);
|
||||||
|
|
||||||
|
return { screenshotUrl, base64 };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[vision] Screenshot error for ${url}:`, err instanceof Error ? err.message : err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +161,9 @@ export async function captureAllScreenshots(
|
||||||
if (pages.surgeryPage) {
|
if (pages.surgeryPage) {
|
||||||
captureTargets.push({ id: 'website-surgery', url: pages.surgeryPage, channel: '웹사이트', caption: '시술 안내 페이지' });
|
captureTargets.push({ id: 'website-surgery', url: pages.surgeryPage, channel: '웹사이트', caption: '시술 안내 페이지' });
|
||||||
}
|
}
|
||||||
|
if (pages.aboutPage) {
|
||||||
|
captureTargets.push({ id: 'website-about', url: pages.aboutPage, channel: '웹사이트', caption: '병원 소개 페이지' });
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube channel landing
|
// YouTube channel landing
|
||||||
const yt = verifiedChannels.youtube as Record<string, unknown> | null;
|
const yt = verifiedChannels.youtube as Record<string, unknown> | null;
|
||||||
|
|
@ -154,16 +193,16 @@ export async function captureAllScreenshots(
|
||||||
|
|
||||||
// Capture all in parallel (max 6 concurrent)
|
// Capture all in parallel (max 6 concurrent)
|
||||||
const capturePromises = captureTargets.map(async (target) => {
|
const capturePromises = captureTargets.map(async (target) => {
|
||||||
const base64 = await captureScreenshot(target.url, firecrawlKey);
|
const result = await captureScreenshot(target.url, firecrawlKey);
|
||||||
if (base64) {
|
if (result) {
|
||||||
results.push({
|
results.push({
|
||||||
id: target.id,
|
id: target.id,
|
||||||
url: `data:image/png;base64,${base64.slice(0, 100)}...`, // Placeholder — will be replaced with Storage URL
|
url: result.screenshotUrl, // GCS URL from Firecrawl (permanent for ~7 days)
|
||||||
channel: target.channel,
|
channel: target.channel,
|
||||||
capturedAt: now,
|
capturedAt: now,
|
||||||
caption: target.caption,
|
caption: target.caption,
|
||||||
sourceUrl: target.url,
|
sourceUrl: target.url,
|
||||||
base64,
|
base64: result.base64,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -181,19 +220,41 @@ export async function analyzeScreenshot(
|
||||||
geminiKey: string,
|
geminiKey: string,
|
||||||
): Promise<VisionAnalysisResult> {
|
): Promise<VisionAnalysisResult> {
|
||||||
const prompts: Record<string, string> = {
|
const prompts: Record<string, string> = {
|
||||||
'website-main': `이 한국 성형외과 병원 메인 페이지 스크린샷을 분석해줘. 다음 정보를 JSON으로 추출해줘:
|
'website-main': `이 한국 성형외과/피부과 병원 메인 페이지 스크린샷을 꼼꼼히 분석해줘. 다음 정보를 JSON으로 추출해줘:
|
||||||
- foundingYear: 개원 연도 (배너에 "SINCE 2004", "21년 무사고" 등이 있으면)
|
|
||||||
|
- foundingYear: 개원 연도. 반드시 찾아줘! 다음 패턴 중 하나라도 있으면 계산해:
|
||||||
|
"22주년" → 2026 - 22 = 2004
|
||||||
|
"22년 동안" → 2026 - 22 = 2004
|
||||||
|
"SINCE 2004" → 2004
|
||||||
|
"20년 전통" → 2026 - 20 = 2006
|
||||||
|
"개원 15주년" → 2026 - 15 = 2011
|
||||||
|
배너, 이벤트 팝업, 로고 옆 텍스트, 하단 footer 등 모든 곳을 확인해줘.
|
||||||
|
- operationYears: 운영 기간 (숫자만. "22주년"이면 22)
|
||||||
- certifications: 인증 마크 (JCI, 보건복지부, 의료관광 등)
|
- certifications: 인증 마크 (JCI, 보건복지부, 의료관광 등)
|
||||||
- socialIcons: 보이는 소셜 미디어 아이콘 (Instagram, YouTube, Facebook, Blog, KakaoTalk 등)
|
- socialIcons: 보이는 소셜 미디어 아이콘 (Instagram, YouTube, Facebook, Blog, KakaoTalk 등)
|
||||||
- floatingButtons: 플로팅 상담 버튼 (카카오톡, LINE, WhatsApp 등)
|
- floatingButtons: 플로팅 상담 버튼 (카카오톡, LINE, WhatsApp 등)
|
||||||
- brandColors: 메인 컬러와 액센트 컬러 (hex)
|
- brandColors: 메인 컬러와 액센트 컬러 (hex)
|
||||||
- slogans: 배너 텍스트나 슬로건
|
- slogans: 배너 텍스트나 슬로건 (이벤트 텍스트 포함)
|
||||||
- serviceCategories: 네비게이션 메뉴에 보이는 시술 카테고리`,
|
- serviceCategories: 네비게이션 메뉴에 보이는 시술 카테고리`,
|
||||||
|
|
||||||
'website-doctors': `이 성형외과 의료진 페이지 스크린샷을 분석해줘. JSON으로 추출:
|
'website-doctors': `이 성형외과 의료진 페이지 스크린샷을 분석해줘. JSON으로 추출:
|
||||||
- doctors: [{name: "이름", specialty: "전문 분야", position: "대표원장/원장 등"}]
|
- doctors: [{name: "이름", specialty: "전문 분야", position: "대표원장/원장 등"}]
|
||||||
프로필 사진 옆에 적힌 이름과 전문 분야를 모두 읽어줘.`,
|
프로필 사진 옆에 적힌 이름과 전문 분야를 모두 읽어줘.`,
|
||||||
|
|
||||||
|
'website-about': `이 성형외과 병원 소개 페이지를 꼼꼼히 분석해줘. JSON으로 추출:
|
||||||
|
|
||||||
|
- foundingYear: 개원 연도. 반드시 찾아줘! 소개 페이지에 자주 나오는 패턴:
|
||||||
|
"22주년" → 2026 - 22 = 2004
|
||||||
|
"22년 동안" → 2026 - 22 = 2004
|
||||||
|
"SINCE 2004" → 2004
|
||||||
|
"2004년 개원" → 2004
|
||||||
|
"20년 전통" → 2026 - 20 = 2006
|
||||||
|
연혁, 소개글, 대표원장 인사말 등 모든 텍스트를 꼼꼼히 확인해줘.
|
||||||
|
- operationYears: 운영 기간 (숫자만)
|
||||||
|
- doctors: [{name: "이름", specialty: "전문 분야", position: "대표원장/원장 등"}]
|
||||||
|
- certifications: 인증 마크 (JCI, 보건복지부, 의료관광 등)
|
||||||
|
- slogans: 소개 텍스트, 미션/비전 문구`,
|
||||||
|
|
||||||
'website-surgery': `이 성형외과 시술 안내 페이지를 분석해줘. JSON으로 추출:
|
'website-surgery': `이 성형외과 시술 안내 페이지를 분석해줘. JSON으로 추출:
|
||||||
- serviceCategories: 보이는 시술 카테고리 목록 (눈성형, 코성형, 가슴성형, 안면윤곽 등)
|
- serviceCategories: 보이는 시술 카테고리 목록 (눈성형, 코성형, 가슴성형, 안면윤곽 등)
|
||||||
- certifications: 보이는 인증/수상 마크`,
|
- certifications: 보이는 인증/수상 마크`,
|
||||||
|
|
@ -212,7 +273,8 @@ export async function analyzeScreenshot(
|
||||||
const prompt = prompts[pageType] || `이 웹페이지 스크린샷을 분석해줘. 보이는 모든 텍스트와 정보를 JSON으로 추출해줘.`;
|
const prompt = prompts[pageType] || `이 웹페이지 스크린샷을 분석해줘. 보이는 모든 텍스트와 정보를 JSON으로 추출해줘.`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
console.log(`[vision] Analyzing screenshot: ${pageType} (${Math.round(base64.length / 1024)}KB)`);
|
||||||
|
const res = await fetchWithRetry(
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -230,18 +292,27 @@ export async function analyzeScreenshot(
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{ label: `gemini-vision:${pageType}`, timeoutMs: 45000, maxRetries: 1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) return {};
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[vision] Gemini failed for ${pageType}: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
return JSON.parse(jsonMatch[0]);
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
console.log(`[vision] Gemini result for ${pageType}:`, JSON.stringify(result).slice(0, 300));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
console.warn(`[vision] Gemini returned no JSON for ${pageType}. Raw: ${text.slice(0, 200)}`);
|
||||||
return {};
|
return {};
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error(`[vision] Gemini error for ${pageType}:`, err instanceof Error ? err.message : err);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
import type { VerifiedChannels } from "../_shared/verifyHandles.ts";
|
||||||
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
|
import { PERPLEXITY_MODEL } from "../_shared/config.ts";
|
||||||
import { captureAllScreenshots, runVisionAnalysis, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
|
import { captureAllScreenshots, runVisionAnalysis, type ScreenshotResult } from "../_shared/visionAnalysis.ts";
|
||||||
|
import { fetchWithRetry, fetchJsonWithRetry, wrapChannelTask, type ChannelTaskResult } from "../_shared/retry.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
|
@ -18,15 +19,20 @@ interface CollectRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runApifyActor(actorId: string, input: Record<string, unknown>, token: string): Promise<unknown[]> {
|
async function runApifyActor(actorId: string, input: Record<string, unknown>, token: string): Promise<unknown[]> {
|
||||||
const res = await fetch(`${APIFY_BASE}/acts/${actorId}/runs?token=${token}&waitForFinish=120`, {
|
const res = await fetchWithRetry(
|
||||||
method: "POST",
|
`${APIFY_BASE}/acts/${actorId}/runs?token=${token}&waitForFinish=120`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input) },
|
||||||
body: JSON.stringify(input),
|
{ maxRetries: 1, timeoutMs: 130000, label: `apify:${actorId.split('~')[1] || actorId}` },
|
||||||
});
|
);
|
||||||
|
if (!res.ok) throw new Error(`Apify ${actorId} returned ${res.status}`);
|
||||||
const run = await res.json();
|
const run = await res.json();
|
||||||
const datasetId = run.data?.defaultDatasetId;
|
const datasetId = run.data?.defaultDatasetId;
|
||||||
if (!datasetId) return [];
|
if (!datasetId) throw new Error(`Apify ${actorId}: no dataset returned`);
|
||||||
const itemsRes = await fetch(`${APIFY_BASE}/datasets/${datasetId}/items?token=${token}&limit=20`);
|
const itemsRes = await fetchWithRetry(
|
||||||
|
`${APIFY_BASE}/datasets/${datasetId}/items?token=${token}&limit=20`,
|
||||||
|
undefined,
|
||||||
|
{ maxRetries: 1, timeoutMs: 30000, label: `apify-dataset:${actorId.split('~')[1] || actorId}` },
|
||||||
|
);
|
||||||
return itemsRes.json();
|
return itemsRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,12 +80,12 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const channelData: Record<string, unknown> = {};
|
const channelData: Record<string, unknown> = {};
|
||||||
const analysisData: Record<string, unknown> = {};
|
const analysisData: Record<string, unknown> = {};
|
||||||
const tasks: Promise<void>[] = [];
|
const channelTasks: Promise<ChannelTaskResult>[] = [];
|
||||||
|
|
||||||
// ─── 1. Instagram (multi-account) — try ALL candidates including unverified ───
|
// ─── 1. Instagram (multi-account) — try ALL candidates including unverified/unverifiable ───
|
||||||
const igCandidates = (verified.instagram || []).filter((v: Record<string, unknown>) => v.handle);
|
const igCandidates = (verified.instagram || []).filter((v: Record<string, unknown>) => v.handle && v.verified !== false);
|
||||||
if (APIFY_TOKEN && igCandidates.length > 0) {
|
if (APIFY_TOKEN && igCandidates.length > 0) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("instagram", async () => {
|
||||||
const accounts: Record<string, unknown>[] = [];
|
const accounts: Record<string, unknown>[] = [];
|
||||||
for (const ig of igCandidates) {
|
for (const ig of igCandidates) {
|
||||||
const items = await runApifyActor("apify~instagram-profile-scraper", { usernames: [ig.handle], resultsLimit: 12 }, APIFY_TOKEN);
|
const items = await runApifyActor("apify~instagram-profile-scraper", { usernames: [ig.handle], resultsLimit: 12 }, APIFY_TOKEN);
|
||||||
|
|
@ -104,14 +110,16 @@ Deno.serve(async (req) => {
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
channelData.instagramAccounts = accounts;
|
channelData.instagramAccounts = accounts;
|
||||||
channelData.instagram = accounts[0];
|
channelData.instagram = accounts[0];
|
||||||
|
} else {
|
||||||
|
throw new Error("No Instagram profiles found via Apify");
|
||||||
}
|
}
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 2. YouTube ───
|
// ─── 2. YouTube ───
|
||||||
const ytVerified = verified.youtube as Record<string, unknown> | null;
|
const ytVerified = verified.youtube as Record<string, unknown> | null;
|
||||||
if (YOUTUBE_API_KEY && ytVerified?.verified) {
|
if (YOUTUBE_API_KEY && (ytVerified?.verified === true || ytVerified?.verified === "unverifiable")) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("youtube", async () => {
|
||||||
const YT = "https://www.googleapis.com/youtube/v3";
|
const YT = "https://www.googleapis.com/youtube/v3";
|
||||||
let channelId = (ytVerified?.channelId as string) || "";
|
let channelId = (ytVerified?.channelId as string) || "";
|
||||||
|
|
||||||
|
|
@ -129,24 +137,24 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!channelId) return;
|
if (!channelId) throw new Error("Could not resolve YouTube channel ID");
|
||||||
|
|
||||||
const chRes = await fetch(`${YT}/channels?part=snippet,statistics,brandingSettings&id=${channelId}&key=${YOUTUBE_API_KEY}`);
|
const chRes = await fetchWithRetry(`${YT}/channels?part=snippet,statistics,brandingSettings&id=${channelId}&key=${YOUTUBE_API_KEY}`, undefined, { label: "youtube-api" });
|
||||||
const chData = await chRes.json();
|
const chData = await chRes.json();
|
||||||
const channel = chData.items?.[0];
|
const channel = chData.items?.[0];
|
||||||
if (!channel) return;
|
if (!channel) throw new Error("YouTube channel not found in API response");
|
||||||
|
|
||||||
const stats = channel.statistics || {};
|
const stats = channel.statistics || {};
|
||||||
const snippet = channel.snippet || {};
|
const snippet = channel.snippet || {};
|
||||||
|
|
||||||
// Popular videos
|
// Popular videos
|
||||||
const searchRes = await fetch(`${YT}/search?part=snippet&channelId=${channelId}&order=viewCount&type=video&maxResults=10&key=${YOUTUBE_API_KEY}`);
|
const searchRes = await fetchWithRetry(`${YT}/search?part=snippet&channelId=${channelId}&order=viewCount&type=video&maxResults=10&key=${YOUTUBE_API_KEY}`, undefined, { label: "youtube-search" });
|
||||||
const searchData = await searchRes.json();
|
const searchData = await searchRes.json();
|
||||||
const videoIds = (searchData.items || []).map((i: Record<string, unknown>) => (i.id as Record<string, string>)?.videoId).filter(Boolean).join(",");
|
const videoIds = (searchData.items || []).map((i: Record<string, unknown>) => (i.id as Record<string, string>)?.videoId).filter(Boolean).join(",");
|
||||||
|
|
||||||
let videos: Record<string, unknown>[] = [];
|
let videos: Record<string, unknown>[] = [];
|
||||||
if (videoIds) {
|
if (videoIds) {
|
||||||
const vRes = await fetch(`${YT}/videos?part=snippet,statistics,contentDetails&id=${videoIds}&key=${YOUTUBE_API_KEY}`);
|
const vRes = await fetchWithRetry(`${YT}/videos?part=snippet,statistics,contentDetails&id=${videoIds}&key=${YOUTUBE_API_KEY}`, undefined, { label: "youtube-videos" });
|
||||||
const vData = await vRes.json();
|
const vData = await vRes.json();
|
||||||
videos = vData.items || [];
|
videos = vData.items || [];
|
||||||
}
|
}
|
||||||
|
|
@ -171,13 +179,13 @@ Deno.serve(async (req) => {
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3. Facebook ───
|
// ─── 3. Facebook ───
|
||||||
const fbVerified = verified.facebook as Record<string, unknown> | null;
|
const fbVerified = verified.facebook as Record<string, unknown> | null;
|
||||||
if (APIFY_TOKEN && fbVerified?.verified) {
|
if (APIFY_TOKEN && (fbVerified?.verified === true || fbVerified?.verified === "unverifiable")) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("facebook", async () => {
|
||||||
const fbUrl = (fbVerified.url as string) || `https://www.facebook.com/${fbVerified.handle}`;
|
const fbUrl = (fbVerified.url as string) || `https://www.facebook.com/${fbVerified.handle}`;
|
||||||
const items = await runApifyActor("apify~facebook-pages-scraper", { startUrls: [{ url: fbUrl }] }, APIFY_TOKEN);
|
const items = await runApifyActor("apify~facebook-pages-scraper", { startUrls: [{ url: fbUrl }] }, APIFY_TOKEN);
|
||||||
const page = (items as Record<string, unknown>[])[0];
|
const page = (items as Record<string, unknown>[])[0];
|
||||||
|
|
@ -189,15 +197,17 @@ Deno.serve(async (req) => {
|
||||||
address: page.address, intro: page.intro, rating: page.rating,
|
address: page.address, intro: page.intro, rating: page.rating,
|
||||||
profilePictureUrl: page.profilePictureUrl,
|
profilePictureUrl: page.profilePictureUrl,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error("Facebook page scraper returned no data");
|
||||||
}
|
}
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 4. 강남언니 ───
|
// ─── 4. 강남언니 ───
|
||||||
const guVerified = verified.gangnamUnni as Record<string, unknown> | null;
|
const guVerified = verified.gangnamUnni as Record<string, unknown> | null;
|
||||||
if (FIRECRAWL_API_KEY && guVerified?.verified && guVerified.url) {
|
if (FIRECRAWL_API_KEY && guVerified?.verified && guVerified.url) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("gangnamUnni", async () => {
|
||||||
const scrapeRes = await fetch("https://api.firecrawl.dev/v1/scrape", {
|
const scrapeRes = await fetchWithRetry("https://api.firecrawl.dev/v1/scrape", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${FIRECRAWL_API_KEY}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -217,7 +227,8 @@ Deno.serve(async (req) => {
|
||||||
},
|
},
|
||||||
waitFor: 5000,
|
waitFor: 5000,
|
||||||
}),
|
}),
|
||||||
});
|
}, { label: "firecrawl-gangnamunni", timeoutMs: 60000 });
|
||||||
|
if (!scrapeRes.ok) throw new Error(`Firecrawl 강남언니 scrape failed: ${scrapeRes.status}`);
|
||||||
const data = await scrapeRes.json();
|
const data = await scrapeRes.json();
|
||||||
const hospital = data.data?.json;
|
const hospital = data.data?.json;
|
||||||
if (hospital?.hospitalName) {
|
if (hospital?.hospitalName) {
|
||||||
|
|
@ -230,22 +241,24 @@ Deno.serve(async (req) => {
|
||||||
procedures: hospital.procedures || [], address: hospital.address,
|
procedures: hospital.procedures || [], address: hospital.address,
|
||||||
badges: hospital.badges || [], sourceUrl: guVerified!.url as string,
|
badges: hospital.badges || [], sourceUrl: guVerified!.url as string,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error("강남언니 scrape returned no hospital data");
|
||||||
}
|
}
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 5. Naver Blog + Place ───
|
// ─── 5. Naver Blog + Place ───
|
||||||
if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET && clinicName) {
|
if (NAVER_CLIENT_ID && NAVER_CLIENT_SECRET && clinicName) {
|
||||||
const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET };
|
const naverHeaders = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET };
|
||||||
|
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("naverBlog", async () => {
|
||||||
// Get verified Naver Blog handle from Phase 1 for official blog URL
|
// Get verified Naver Blog handle from Phase 1 for official blog URL
|
||||||
const nbVerified = verified.naverBlog as Record<string, unknown> | null;
|
const nbVerified = verified.naverBlog as Record<string, unknown> | null;
|
||||||
const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null;
|
const officialBlogHandle = nbVerified?.handle ? String(nbVerified.handle) : null;
|
||||||
|
|
||||||
const query = encodeURIComponent(`${clinicName} 후기`);
|
const query = encodeURIComponent(`${clinicName} 후기`);
|
||||||
const res = await fetch(`https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, { headers: naverHeaders });
|
const res = await fetchWithRetry(`https://openapi.naver.com/v1/search/blog.json?query=${query}&display=10&sort=sim`, { headers: naverHeaders }, { label: "naver-blog" });
|
||||||
if (!res.ok) return;
|
if (!res.ok) throw new Error(`Naver Blog API returned ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
channelData.naverBlog = {
|
channelData.naverBlog = {
|
||||||
totalResults: data.total || 0, searchQuery: `${clinicName} 후기`,
|
totalResults: data.total || 0, searchQuery: `${clinicName} 후기`,
|
||||||
|
|
@ -259,9 +272,9 @@ Deno.serve(async (req) => {
|
||||||
link: item.link, bloggerName: item.bloggername, postDate: item.postdate,
|
link: item.link, bloggerName: item.bloggername, postDate: item.postdate,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
})());
|
}));
|
||||||
|
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("naverPlace", async () => {
|
||||||
// Try multiple queries to find the correct place (avoid same-name different clinics)
|
// Try multiple queries to find the correct place (avoid same-name different clinics)
|
||||||
const queries = [
|
const queries = [
|
||||||
`${clinicName} 성형외과`,
|
`${clinicName} 성형외과`,
|
||||||
|
|
@ -270,7 +283,7 @@ Deno.serve(async (req) => {
|
||||||
];
|
];
|
||||||
for (const q of queries) {
|
for (const q of queries) {
|
||||||
const query = encodeURIComponent(q);
|
const query = encodeURIComponent(q);
|
||||||
const res = await fetch(`https://openapi.naver.com/v1/search/local.json?query=${query}&display=5&sort=comment`, { headers: naverHeaders });
|
const res = await fetchWithRetry(`https://openapi.naver.com/v1/search/local.json?query=${query}&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();
|
||||||
// Find the best match: prefer category containing 성형 or 피부
|
// Find the best match: prefer category containing 성형 or 피부
|
||||||
|
|
@ -291,12 +304,12 @@ Deno.serve(async (req) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 6. Google Maps ───
|
// ─── 6. Google Maps ───
|
||||||
if (APIFY_TOKEN && clinicName) {
|
if (APIFY_TOKEN && clinicName) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("googleMaps", async () => {
|
||||||
const queries = [`${clinicName} 성형외과`, clinicName, `${clinicName} ${address || "강남"}`];
|
const queries = [`${clinicName} 성형외과`, clinicName, `${clinicName} ${address || "강남"}`];
|
||||||
let items: unknown[] = [];
|
let items: unknown[] = [];
|
||||||
for (const q of queries) {
|
for (const q of queries) {
|
||||||
|
|
@ -317,13 +330,15 @@ Deno.serve(async (req) => {
|
||||||
stars: r.stars, text: r.text, publishedAtDate: r.publishedAtDate,
|
stars: r.stars, text: r.text, publishedAtDate: r.publishedAtDate,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error("Google Maps: no matching place found");
|
||||||
}
|
}
|
||||||
})());
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 7. Market Analysis (Perplexity) ───
|
// ─── 7. Market Analysis (Perplexity) ───
|
||||||
if (PERPLEXITY_API_KEY && services.length > 0) {
|
if (PERPLEXITY_API_KEY && services.length > 0) {
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("marketAnalysis", async () => {
|
||||||
const queries = [
|
const queries = [
|
||||||
{ id: "competitors", prompt: `${address || "강남"} 근처 ${services.slice(0, 3).join(", ")} 전문 성형외과/피부과 경쟁 병원 5곳을 분석해줘. 각 병원의 이름, 주요 시술, 온라인 평판, 마케팅 채널을 JSON 형식으로 제공해줘.` },
|
{ id: "competitors", prompt: `${address || "강남"} 근처 ${services.slice(0, 3).join(", ")} 전문 성형외과/피부과 경쟁 병원 5곳을 분석해줘. 각 병원의 이름, 주요 시술, 온라인 평판, 마케팅 채널을 JSON 형식으로 제공해줘.` },
|
||||||
{ id: "keywords", prompt: `한국 ${services.slice(0, 3).join(", ")} 관련 검색 키워드 트렌드. 네이버와 구글에서 월간 검색량이 높은 키워드 20개, 경쟁 강도, 추천 롱테일 키워드를 JSON 형식으로 제공해줘.` },
|
{ id: "keywords", prompt: `한국 ${services.slice(0, 3).join(", ")} 관련 검색 키워드 트렌드. 네이버와 구글에서 월간 검색량이 높은 키워드 20개, 경쟁 강도, 추천 롱테일 키워드를 JSON 형식으로 제공해줘.` },
|
||||||
|
|
@ -332,7 +347,7 @@ Deno.serve(async (req) => {
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = await Promise.allSettled(queries.map(async q => {
|
const results = await Promise.allSettled(queries.map(async q => {
|
||||||
const res = await fetch("https://api.perplexity.ai/chat/completions", {
|
const res = await fetchWithRetry("https://api.perplexity.ai/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -341,11 +356,12 @@ Deno.serve(async (req) => {
|
||||||
{ role: "user", content: q.prompt },
|
{ role: "user", content: q.prompt },
|
||||||
], temperature: 0.3,
|
], temperature: 0.3,
|
||||||
}),
|
}),
|
||||||
});
|
}, { label: `perplexity:${q.id}`, timeoutMs: 60000 });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return { id: q.id, content: data.choices?.[0]?.message?.content || "", citations: data.citations || [] };
|
return { id: q.id, content: data.choices?.[0]?.message?.content || "", citations: data.citations || [] };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.status === "fulfilled") {
|
if (r.status === "fulfilled") {
|
||||||
const { id, content, citations } = r.value;
|
const { id, content, citations } = r.value;
|
||||||
|
|
@ -353,9 +369,11 @@ Deno.serve(async (req) => {
|
||||||
const jsonMatch = content.match(/```json\n?([\s\S]*?)```/);
|
const jsonMatch = content.match(/```json\n?([\s\S]*?)```/);
|
||||||
if (jsonMatch) { try { parsed = JSON.parse(jsonMatch[1]); } catch {} }
|
if (jsonMatch) { try { parsed = JSON.parse(jsonMatch[1]); } catch {} }
|
||||||
analysisData[id] = { data: parsed, citations };
|
analysisData[id] = { data: parsed, citations };
|
||||||
|
successCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})());
|
if (successCount === 0) throw new Error("All Perplexity queries failed");
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 8. Vision Analysis: Screenshots + Gemini Vision ───
|
// ─── 8. Vision Analysis: Screenshots + Gemini Vision ───
|
||||||
|
|
@ -366,7 +384,7 @@ Deno.serve(async (req) => {
|
||||||
const mainUrl = row.url || "";
|
const mainUrl = row.url || "";
|
||||||
const siteMap: string[] = row.scrape_data?.siteMap || [];
|
const siteMap: string[] = row.scrape_data?.siteMap || [];
|
||||||
|
|
||||||
tasks.push((async () => {
|
channelTasks.push(wrapChannelTask("vision", async () => {
|
||||||
// Capture screenshots of relevant pages + social channel landings
|
// Capture screenshots of relevant pages + social channel landings
|
||||||
screenshots = await captureAllScreenshots(mainUrl, siteMap, verified, FIRECRAWL_API_KEY);
|
screenshots = await captureAllScreenshots(mainUrl, siteMap, verified, FIRECRAWL_API_KEY);
|
||||||
|
|
||||||
|
|
@ -386,17 +404,46 @@ Deno.serve(async (req) => {
|
||||||
caption: ss.caption,
|
caption: ss.caption,
|
||||||
sourceUrl: ss.sourceUrl,
|
sourceUrl: ss.sourceUrl,
|
||||||
}));
|
}));
|
||||||
})());
|
|
||||||
|
if (screenshots.length === 0) throw new Error("No screenshots captured");
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Execute all tasks ───
|
// ─── Execute all channel tasks ───
|
||||||
await Promise.allSettled(tasks);
|
const taskResults = await Promise.all(channelTasks);
|
||||||
|
|
||||||
// ─── Legacy: Save to marketing_reports ───
|
// ─── Build channelErrors from task results ───
|
||||||
|
const channelErrors: Record<string, { error: string; durationMs: number }> = {};
|
||||||
|
let failedCount = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
for (const result of taskResults) {
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
channelErrors[result.channel] = {
|
||||||
|
error: result.error || "Unknown error",
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTasks = taskResults.length;
|
||||||
|
const isPartial = failedCount > 0 && successCount > 0;
|
||||||
|
const isFullFailure = failedCount > 0 && successCount === 0;
|
||||||
|
const collectionStatus = isFullFailure ? "collection_failed" : isPartial ? "partial" : "collected";
|
||||||
|
|
||||||
|
console.log(`[collect] ${successCount}/${totalTasks} tasks succeeded. Status: ${collectionStatus}`);
|
||||||
|
if (failedCount > 0) {
|
||||||
|
console.warn(`[collect] Failed channels:`, JSON.stringify(channelErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UNCONDITIONAL Legacy Save: always persist whatever we have ───
|
||||||
await supabase.from("marketing_reports").update({
|
await supabase.from("marketing_reports").update({
|
||||||
channel_data: channelData,
|
channel_data: channelData,
|
||||||
analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() },
|
analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() },
|
||||||
status: "collected",
|
channel_errors: channelErrors,
|
||||||
|
status: collectionStatus,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}).eq("id", reportId);
|
}).eq("id", reportId);
|
||||||
|
|
||||||
|
|
@ -479,12 +526,13 @@ Deno.serve(async (req) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update analysis_run
|
// Update analysis_run with status + errors
|
||||||
await supabase.from("analysis_runs").update({
|
await supabase.from("analysis_runs").update({
|
||||||
raw_channel_data: channelData,
|
raw_channel_data: channelData,
|
||||||
analysis_data: { clinicName, services, address, analysis: analysisData },
|
analysis_data: { clinicName, services, address, analysis: analysisData },
|
||||||
vision_analysis: channelData.visionAnalysis || {},
|
vision_analysis: channelData.visionAnalysis || {},
|
||||||
status: "collecting",
|
channel_errors: channelErrors,
|
||||||
|
status: collectionStatus,
|
||||||
}).eq("id", runId);
|
}).eq("id", runId);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -493,7 +541,16 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }),
|
JSON.stringify({
|
||||||
|
success: !isFullFailure,
|
||||||
|
status: collectionStatus,
|
||||||
|
channelData,
|
||||||
|
analysisData,
|
||||||
|
channelErrors: Object.keys(channelErrors).length > 0 ? channelErrors : undefined,
|
||||||
|
partialFailure: isPartial,
|
||||||
|
taskSummary: { total: totalTasks, succeeded: successCount, failed: failedCount },
|
||||||
|
collectedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,35 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
|
||||||
let report;
|
let report;
|
||||||
try { report = JSON.parse(reportText); } catch { report = { raw: reportText, parseError: true }; }
|
try { report = JSON.parse(reportText); } catch { report = { raw: reportText, parseError: true }; }
|
||||||
|
|
||||||
|
// ─── Post-processing: Inject Vision Analysis data directly ───
|
||||||
|
// Perplexity may ignore Vision data in prompt, so we force-inject critical fields
|
||||||
|
const vision = channelData.visionAnalysis as Record<string, unknown> | undefined;
|
||||||
|
if (vision) {
|
||||||
|
// Force-inject foundingYear if Vision found it but Perplexity didn't
|
||||||
|
if (vision.foundingYear && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
|
||||||
|
report.clinicInfo = report.clinicInfo || {};
|
||||||
|
report.clinicInfo.established = String(vision.foundingYear);
|
||||||
|
console.log(`[report] Injected foundingYear from Vision: ${vision.foundingYear}`);
|
||||||
|
}
|
||||||
|
if (vision.operationYears && (!report.clinicInfo?.established || report.clinicInfo.established === "데이터 없음")) {
|
||||||
|
const year = new Date().getFullYear() - Number(vision.operationYears);
|
||||||
|
report.clinicInfo = report.clinicInfo || {};
|
||||||
|
report.clinicInfo.established = String(year);
|
||||||
|
console.log(`[report] Calculated foundingYear from operationYears: ${vision.operationYears} → ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-inject doctors from Vision if report has none
|
||||||
|
const visionDoctors = vision.doctors as { name: string; specialty: string; position?: string }[] | undefined;
|
||||||
|
if (visionDoctors?.length && (!report.clinicInfo?.doctors?.length)) {
|
||||||
|
report.clinicInfo = report.clinicInfo || {};
|
||||||
|
report.clinicInfo.doctors = visionDoctors;
|
||||||
|
console.log(`[report] Injected ${visionDoctors.length} doctors from Vision`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store Vision analysis separately for frontend
|
||||||
|
report.visionAnalysis = vision;
|
||||||
|
}
|
||||||
|
|
||||||
// Embed channel enrichment data for frontend mergeEnrichment()
|
// Embed channel enrichment data for frontend mergeEnrichment()
|
||||||
report.channelEnrichment = channelData;
|
report.channelEnrichment = channelData;
|
||||||
report.enrichedAt = new Date().toISOString();
|
report.enrichedAt = new Date().toISOString();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Add channel_errors column to marketing_reports for error tracking
|
||||||
|
ALTER TABLE marketing_reports ADD COLUMN IF NOT EXISTS channel_errors JSONB DEFAULT '{}';
|
||||||
Loading…
Reference in New Issue