feat: 사이트 전체 검사 기능 추가

도메인 하위 링크를 BFS로 자동 크롤링하여 페이지별 검사 수행.
- BFS 링크 크롤러 (같은 도메인 필터링, max_pages/max_depth 설정)
- 사이트 검사 오케스트레이션 (크롤링→순차 검사→집계)
- SSE 실시간 진행 상태 (크롤링/검사/완료)
- 페이지 트리 + 집계 결과 UI
- UrlInputForm에 "사이트 전체 검사" 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 16:46:49 +09:00
parent 44ad36e2ab
commit 81b9104aea
21 changed files with 3238 additions and 56 deletions

View File

@ -0,0 +1,142 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore";
import { api } from "@/lib/api";
import type {
SSECrawlProgress,
SSECrawlComplete,
SSEPageStart,
SSEPageComplete,
SSEAggregateUpdate,
SSESiteComplete,
} from "@/types/site-inspection";
/**
* SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
* Zustand 스토어를 업데이트한다.
*/
export function useSiteInspectionSSE(siteInspectionId: string | null) {
const {
setCrawlProgress,
setCrawlComplete,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
} = useSiteInspectionStore();
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!siteInspectionId) return;
const streamUrl = api.getSiteStreamUrl(siteInspectionId);
const eventSource = new EventSource(streamUrl);
eventSourceRef.current = eventSource;
/** 크롤링 진행 이벤트 */
eventSource.addEventListener("crawl_progress", (e: MessageEvent) => {
try {
const data: SSECrawlProgress = JSON.parse(e.data);
setCrawlProgress(data.pages_found, data.current_url);
} catch {
// JSON 파싱 실패 무시
}
});
/** 크롤링 완료 이벤트 */
eventSource.addEventListener("crawl_complete", (e: MessageEvent) => {
try {
const data: SSECrawlComplete = JSON.parse(e.data);
setCrawlComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 시작 이벤트 */
eventSource.addEventListener("page_start", (e: MessageEvent) => {
try {
const data: SSEPageStart = JSON.parse(e.data);
updatePageStatus(data.page_url, "inspecting");
} catch {
// JSON 파싱 실패 무시
}
});
/** 개별 페이지 검사 완료 이벤트 */
eventSource.addEventListener("page_complete", (e: MessageEvent) => {
try {
const data: SSEPageComplete = JSON.parse(e.data);
setPageComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 집계 점수 업데이트 이벤트 */
eventSource.addEventListener("aggregate_update", (e: MessageEvent) => {
try {
const data: SSEAggregateUpdate = JSON.parse(e.data);
updateAggregateScores(data);
} catch {
// JSON 파싱 실패 무시
}
});
/** 사이트 검사 완료 이벤트 */
eventSource.addEventListener("complete", (e: MessageEvent) => {
try {
const data: SSESiteComplete = JSON.parse(e.data);
setCompleted(data.aggregate_scores);
eventSource.close();
// 결과 페이지로 자동 이동
router.push(`/site-inspections/${siteInspectionId}`);
} catch {
// JSON 파싱 실패 무시
}
});
/** 에러 이벤트 */
eventSource.addEventListener("error", (e: Event) => {
if (e instanceof MessageEvent) {
try {
const data = JSON.parse(e.data);
setError(data.message || "사이트 검사 중 오류가 발생했습니다");
} catch {
setError("사이트 검사 중 오류가 발생했습니다");
}
}
// 네트워크 에러인 경우
if (eventSource.readyState === EventSource.CLOSED) {
setError("서버와의 연결이 끊어졌습니다");
}
});
// SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨)
const timeout = setTimeout(() => {
eventSource.close();
setError("사이트 검사 시간이 초과되었습니다 (10분)");
}, 600000);
return () => {
clearTimeout(timeout);
eventSource.close();
eventSourceRef.current = null;
};
}, [
siteInspectionId,
setCrawlProgress,
setCrawlComplete,
updatePageStatus,
setPageComplete,
updateAggregateScores,
setCompleted,
setError,
router,
]);
}