feat: 웹사이트 표준화 검사 도구 구현

- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목)
- FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트)
- Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트)
- Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392)
- 전체 테스트 32/32 PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 13:57:27 +09:00
parent c37cda5b13
commit b5fa5d96b9
93 changed files with 18735 additions and 22 deletions

148
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,148 @@
import type {
StartInspectionResponse,
InspectionResult,
IssueListResponse,
IssueFilters,
PaginatedResponse,
HistoryParams,
TrendResponse,
} from "@/types/inspection";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8011";
/** API 에러 클래스 */
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
/** API 클라이언트 */
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
let detail = "요청 처리 중 오류가 발생했습니다";
try {
const error = await response.json();
detail = error.detail || detail;
} catch {
// JSON 파싱 실패 시 기본 메시지 사용
}
throw new ApiError(response.status, detail);
}
return response.json();
}
/** 검사 시작 */
async startInspection(url: string): Promise<StartInspectionResponse> {
return this.request("/api/inspections", {
method: "POST",
body: JSON.stringify({ url }),
});
}
/** 검사 결과 조회 */
async getInspection(id: string): Promise<InspectionResult> {
return this.request(`/api/inspections/${id}`);
}
/** 이슈 목록 조회 */
async getIssues(
id: string,
filters?: IssueFilters
): Promise<IssueListResponse> {
const params = new URLSearchParams();
if (filters?.category && filters.category !== "all") {
params.set("category", filters.category);
}
if (filters?.severity && filters.severity !== "all") {
params.set("severity", filters.severity);
}
const qs = params.toString();
return this.request(`/api/inspections/${id}/issues${qs ? `?${qs}` : ""}`);
}
/** 이력 목록 조회 (페이지네이션) */
async getInspections(params: HistoryParams): Promise<PaginatedResponse> {
const qs = new URLSearchParams();
qs.set("page", String(params.page || 1));
qs.set("limit", String(params.limit || 20));
if (params.url) qs.set("url", params.url);
if (params.sort) qs.set("sort", params.sort);
return this.request(`/api/inspections?${qs}`);
}
/** 트렌드 데이터 조회 */
async getTrend(url: string, limit = 10): Promise<TrendResponse> {
const qs = new URLSearchParams({ url, limit: String(limit) });
return this.request(`/api/inspections/trend?${qs}`);
}
/** PDF 다운로드 */
async downloadPdf(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/pdf`
);
if (!response.ok) {
throw new ApiError(response.status, "PDF 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** JSON 다운로드 */
async downloadJson(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/json`
);
if (!response.ok) {
throw new ApiError(response.status, "JSON 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** SSE 스트림 URL 반환 */
getStreamUrl(inspectionId: string): string {
return `${this.baseUrl}/api/inspections/${inspectionId}/stream`;
}
}
export const api = new ApiClient(API_BASE_URL);