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:
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
34
backend/app/core/config.py
Normal file
34
backend/app/core/config.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""
|
||||
Application configuration from environment variables.
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URL: str = "mongodb://admin:password123@localhost:27022/"
|
||||
DB_NAME: str = "web_inspector"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6392"
|
||||
|
||||
# Inspection
|
||||
URL_FETCH_TIMEOUT: int = 10
|
||||
CATEGORY_TIMEOUT: int = 60
|
||||
MAX_HTML_SIZE: int = 10485760 # 10MB
|
||||
|
||||
# Application
|
||||
PROJECT_NAME: str = "Web Inspector API"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
49
backend/app/core/database.py
Normal file
49
backend/app/core/database.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""
|
||||
MongoDB connection management using Motor async driver.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: AsyncIOMotorClient | None = None
|
||||
_db: AsyncIOMotorDatabase | None = None
|
||||
|
||||
|
||||
async def connect_db() -> None:
|
||||
"""Establish MongoDB connection and create indexes."""
|
||||
global _client, _db
|
||||
settings = get_settings()
|
||||
_client = AsyncIOMotorClient(settings.MONGODB_URL)
|
||||
_db = _client[settings.DB_NAME]
|
||||
|
||||
# Create indexes
|
||||
await _db.inspections.create_index("inspection_id", unique=True)
|
||||
await _db.inspections.create_index([("url", 1), ("created_at", -1)])
|
||||
await _db.inspections.create_index([("created_at", -1)])
|
||||
|
||||
# Verify connection
|
||||
await _client.admin.command("ping")
|
||||
logger.info("MongoDB connected successfully: %s", settings.DB_NAME)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close MongoDB connection."""
|
||||
global _client, _db
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
_db = None
|
||||
logger.info("MongoDB connection closed")
|
||||
|
||||
|
||||
def get_db() -> AsyncIOMotorDatabase:
|
||||
"""
|
||||
Get database instance.
|
||||
Uses 'if db is None' pattern for pymongo>=4.9 compatibility.
|
||||
"""
|
||||
if _db is None:
|
||||
raise RuntimeError("Database is not connected. Call connect_db() first.")
|
||||
return _db
|
||||
110
backend/app/core/redis.py
Normal file
110
backend/app/core/redis.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
Redis connection management using redis-py async client.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from redis.asyncio import Redis
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_redis: Redis | None = None
|
||||
|
||||
|
||||
async def connect_redis() -> None:
|
||||
"""Establish Redis connection."""
|
||||
global _redis
|
||||
settings = get_settings()
|
||||
_redis = Redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
decode_responses=True,
|
||||
)
|
||||
# Verify connection
|
||||
await _redis.ping()
|
||||
logger.info("Redis connected successfully: %s", settings.REDIS_URL)
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
"""Close Redis connection."""
|
||||
global _redis
|
||||
if _redis is not None:
|
||||
await _redis.close()
|
||||
_redis = None
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""Get Redis instance."""
|
||||
if _redis is None:
|
||||
raise RuntimeError("Redis is not connected. Call connect_redis() first.")
|
||||
return _redis
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
PROGRESS_TTL = 300 # 5 minutes
|
||||
RESULT_CACHE_TTL = 3600 # 1 hour
|
||||
RECENT_LIST_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
async def set_inspection_status(inspection_id: str, status: str) -> None:
|
||||
"""Set inspection status in Redis with TTL."""
|
||||
r = get_redis()
|
||||
key = f"inspection:{inspection_id}:status"
|
||||
await r.set(key, status, ex=PROGRESS_TTL)
|
||||
|
||||
|
||||
async def get_inspection_status(inspection_id: str) -> str | None:
|
||||
"""Get inspection status from Redis."""
|
||||
r = get_redis()
|
||||
key = f"inspection:{inspection_id}:status"
|
||||
return await r.get(key)
|
||||
|
||||
|
||||
async def update_category_progress(
|
||||
inspection_id: str, category: str, progress: int, current_step: str
|
||||
) -> None:
|
||||
"""Update category progress in Redis hash."""
|
||||
r = get_redis()
|
||||
key = f"inspection:{inspection_id}:progress"
|
||||
await r.hset(key, mapping={
|
||||
f"{category}_progress": str(progress),
|
||||
f"{category}_step": current_step,
|
||||
f"{category}_status": "completed" if progress >= 100 else "running",
|
||||
})
|
||||
await r.expire(key, PROGRESS_TTL)
|
||||
|
||||
|
||||
async def get_current_progress(inspection_id: str) -> dict | None:
|
||||
"""Get current progress data from Redis."""
|
||||
r = get_redis()
|
||||
key = f"inspection:{inspection_id}:progress"
|
||||
data = await r.hgetall(key)
|
||||
if not data:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def publish_event(inspection_id: str, event_data: dict) -> None:
|
||||
"""Publish an SSE event via Redis Pub/Sub."""
|
||||
r = get_redis()
|
||||
channel = f"inspection:{inspection_id}:events"
|
||||
await r.publish(channel, json.dumps(event_data, ensure_ascii=False))
|
||||
|
||||
|
||||
async def cache_result(inspection_id: str, result: dict) -> None:
|
||||
"""Cache inspection result in Redis."""
|
||||
r = get_redis()
|
||||
key = f"inspection:result:{inspection_id}"
|
||||
await r.set(key, json.dumps(result, ensure_ascii=False, default=str), ex=RESULT_CACHE_TTL)
|
||||
|
||||
|
||||
async def get_cached_result(inspection_id: str) -> dict | None:
|
||||
"""Get cached inspection result from Redis."""
|
||||
r = get_redis()
|
||||
key = f"inspection:result:{inspection_id}"
|
||||
data = await r.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
0
backend/app/engines/__init__.py
Normal file
0
backend/app/engines/__init__.py
Normal file
422
backend/app/engines/accessibility.py
Normal file
422
backend/app/engines/accessibility.py
Normal file
@ -0,0 +1,422 @@
|
||||
"""
|
||||
Accessibility (WCAG 2.1 AA) Checker Engine (F-003).
|
||||
Uses Playwright + axe-core for comprehensive accessibility testing.
|
||||
Falls back to BeautifulSoup-based checks if Playwright is unavailable.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.engines.base import BaseChecker
|
||||
from app.models.schemas import CategoryResult, Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# axe-core JS file path
|
||||
AXE_CORE_JS_PATH = Path(__file__).parent / "axe_core" / "axe.min.js"
|
||||
|
||||
# Korean message mapping for axe-core rules
|
||||
AXE_RULE_MESSAGES = {
|
||||
"image-alt": ("A-01", "이미지에 대체 텍스트(alt)가 없습니다", "1.1.1"),
|
||||
"color-contrast": ("A-02", "텍스트와 배경의 색상 대비가 부족합니다", "1.4.3"),
|
||||
"keyboard": ("A-03", "키보드로 접근할 수 없는 요소가 있습니다", "2.1.1"),
|
||||
"focus-visible": ("A-04", "키보드 포커스가 시각적으로 표시되지 않습니다", "2.4.7"),
|
||||
"label": ("A-05", "폼 요소에 레이블이 연결되지 않았습니다", "1.3.1"),
|
||||
"input-label": ("A-05", "입력 요소에 레이블이 없습니다", "1.3.1"),
|
||||
"aria-valid-attr": ("A-06", "유효하지 않은 ARIA 속성이 사용되었습니다", "4.1.2"),
|
||||
"aria-roles": ("A-06", "유효하지 않은 ARIA 역할이 사용되었습니다", "4.1.2"),
|
||||
"aria-required-attr": ("A-06", "필수 ARIA 속성이 누락되었습니다", "4.1.2"),
|
||||
"aria-valid-attr-value": ("A-06", "ARIA 속성 값이 올바르지 않습니다", "4.1.2"),
|
||||
"link-name": ("A-07", "링크 텍스트가 목적을 설명하지 않습니다", "2.4.4"),
|
||||
"html-has-lang": ("A-08", "HTML 요소에 lang 속성이 없습니다", "3.1.1"),
|
||||
"html-lang-valid": ("A-08", "HTML lang 속성 값이 올바르지 않습니다", "3.1.1"),
|
||||
"bypass": ("A-09", "건너뛰기 링크(skip navigation)가 없습니다", "2.4.1"),
|
||||
"no-autoplay-audio": ("A-10", "자동 재생 미디어에 정지/음소거 컨트롤이 없습니다", "1.4.2"),
|
||||
"audio-caption": ("A-10", "오디오/비디오에 자막이 없습니다", "1.2.2"),
|
||||
"video-caption": ("A-10", "비디오에 자막이 없습니다", "1.2.2"),
|
||||
}
|
||||
|
||||
# axe-core impact to severity mapping
|
||||
IMPACT_TO_SEVERITY = {
|
||||
"critical": "critical",
|
||||
"serious": "major",
|
||||
"moderate": "minor",
|
||||
"minor": "info",
|
||||
}
|
||||
|
||||
|
||||
class AccessibilityChecker(BaseChecker):
|
||||
"""Accessibility (WCAG 2.1 AA) checker engine."""
|
||||
|
||||
@property
|
||||
def category_name(self) -> str:
|
||||
return "accessibility"
|
||||
|
||||
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
||||
"""
|
||||
Primary: Playwright + axe-core.
|
||||
Fallback: BeautifulSoup-based basic checks.
|
||||
"""
|
||||
try:
|
||||
return await self._check_with_playwright(url)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Playwright accessibility check failed, falling back to basic checks: %s",
|
||||
str(e),
|
||||
)
|
||||
return await self._check_with_beautifulsoup(url, html_content)
|
||||
|
||||
async def _check_with_playwright(self, url: str) -> CategoryResult:
|
||||
"""Run axe-core via Playwright headless browser."""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
await self.update_progress(10, "브라우저 시작 중...")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page()
|
||||
|
||||
await self.update_progress(20, "페이지 로드 중...")
|
||||
await page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
|
||||
await self.update_progress(40, "axe-core 주입 중...")
|
||||
# Load axe-core JS
|
||||
if AXE_CORE_JS_PATH.exists() and AXE_CORE_JS_PATH.stat().st_size > 1000:
|
||||
axe_js = AXE_CORE_JS_PATH.read_text(encoding="utf-8")
|
||||
await page.evaluate(axe_js)
|
||||
else:
|
||||
# Fallback: load from CDN
|
||||
await page.evaluate("""
|
||||
async () => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js';
|
||||
document.head.appendChild(script);
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
});
|
||||
}
|
||||
""")
|
||||
|
||||
await self.update_progress(60, "접근성 검사 실행 중...")
|
||||
axe_results = await page.evaluate("""
|
||||
() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof axe === 'undefined') {
|
||||
reject(new Error('axe-core not loaded'));
|
||||
return;
|
||||
}
|
||||
axe.run(document, {
|
||||
runOnly: {
|
||||
type: 'tag',
|
||||
values: ['wcag2a', 'wcag2aa', 'best-practice']
|
||||
}
|
||||
}).then(resolve).catch(reject);
|
||||
});
|
||||
}
|
||||
""")
|
||||
|
||||
await self.update_progress(80, "결과 분석 중...")
|
||||
issues = self._parse_axe_results(axe_results)
|
||||
score = self._calculate_axe_score(axe_results)
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
await self.update_progress(100, "완료")
|
||||
return self._build_result(
|
||||
category="accessibility",
|
||||
score=score,
|
||||
issues=issues,
|
||||
wcag_level="AA",
|
||||
)
|
||||
|
||||
async def _check_with_beautifulsoup(self, url: str, html_content: str) -> CategoryResult:
|
||||
"""Fallback: basic accessibility checks using BeautifulSoup."""
|
||||
soup = BeautifulSoup(html_content, "html5lib")
|
||||
issues: list[Issue] = []
|
||||
|
||||
await self.update_progress(20, "이미지 대체 텍스트 검사 중...")
|
||||
issues += self._bs_check_img_alt(soup)
|
||||
|
||||
await self.update_progress(35, "폼 레이블 검사 중...")
|
||||
issues += self._bs_check_form_labels(soup)
|
||||
|
||||
await self.update_progress(50, "ARIA 속성 검사 중...")
|
||||
issues += self._bs_check_aria(soup)
|
||||
|
||||
await self.update_progress(60, "링크 텍스트 검사 중...")
|
||||
issues += self._bs_check_link_text(soup)
|
||||
|
||||
await self.update_progress(70, "언어 속성 검사 중...")
|
||||
issues += self._bs_check_lang(soup)
|
||||
|
||||
await self.update_progress(80, "건너뛰기 링크 검사 중...")
|
||||
issues += self._bs_check_skip_nav(soup)
|
||||
|
||||
await self.update_progress(90, "자동 재생 검사 중...")
|
||||
issues += self._bs_check_autoplay(soup)
|
||||
|
||||
score = self._calculate_score_by_deduction(issues)
|
||||
await self.update_progress(100, "완료")
|
||||
|
||||
return self._build_result(
|
||||
category="accessibility",
|
||||
score=score,
|
||||
issues=issues,
|
||||
wcag_level="AA",
|
||||
)
|
||||
|
||||
def _parse_axe_results(self, axe_results: dict) -> list[Issue]:
|
||||
"""Convert axe-core violations to Issue list with Korean messages."""
|
||||
issues = []
|
||||
|
||||
for violation in axe_results.get("violations", []):
|
||||
rule_id = violation.get("id", "")
|
||||
impact = violation.get("impact", "minor")
|
||||
severity = IMPACT_TO_SEVERITY.get(impact, "info")
|
||||
|
||||
# Map to our issue codes
|
||||
if rule_id in AXE_RULE_MESSAGES:
|
||||
code, korean_msg, wcag = AXE_RULE_MESSAGES[rule_id]
|
||||
else:
|
||||
code = "A-06"
|
||||
korean_msg = violation.get("description", "접근성 위반 사항이 발견되었습니다")
|
||||
wcag = "4.1.2"
|
||||
|
||||
# Get affected elements
|
||||
nodes = violation.get("nodes", [])
|
||||
element = None
|
||||
if nodes:
|
||||
html_snippet = nodes[0].get("html", "")
|
||||
if html_snippet:
|
||||
element = html_snippet[:200]
|
||||
|
||||
# Additional context for color contrast
|
||||
detail = ""
|
||||
if rule_id == "color-contrast" and nodes:
|
||||
data = nodes[0].get("any", [{}])
|
||||
if data and isinstance(data, list) and len(data) > 0:
|
||||
msg_data = data[0].get("data", {})
|
||||
if isinstance(msg_data, dict):
|
||||
fg = msg_data.get("fgColor", "")
|
||||
bg = msg_data.get("bgColor", "")
|
||||
ratio = msg_data.get("contrastRatio", "")
|
||||
if ratio:
|
||||
detail = f" (대비율: {ratio}:1, 최소 4.5:1 필요)"
|
||||
|
||||
# Create the issue with node count info
|
||||
node_count = len(nodes)
|
||||
count_info = f" ({node_count}개 요소)" if node_count > 1 else ""
|
||||
|
||||
issues.append(self._create_issue(
|
||||
code=code,
|
||||
severity=severity,
|
||||
message=f"{korean_msg}{detail}{count_info}",
|
||||
element=element,
|
||||
suggestion=violation.get("helpUrl", "해당 WCAG 기준을 확인하고 수정하세요"),
|
||||
wcag_criterion=wcag,
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _calculate_axe_score(self, axe_results: dict) -> int:
|
||||
"""
|
||||
Calculate score based on axe-core violations.
|
||||
critical=-20, serious=-10, moderate=-5, minor=-2
|
||||
"""
|
||||
severity_weights = {
|
||||
"critical": 20,
|
||||
"serious": 10,
|
||||
"moderate": 5,
|
||||
"minor": 2,
|
||||
}
|
||||
deduction = 0
|
||||
for violation in axe_results.get("violations", []):
|
||||
impact = violation.get("impact", "minor")
|
||||
deduction += severity_weights.get(impact, 2)
|
||||
return max(0, 100 - deduction)
|
||||
|
||||
# --- BeautifulSoup fallback checks ---
|
||||
|
||||
def _bs_check_img_alt(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-01: Check images for alt text."""
|
||||
issues = []
|
||||
images = soup.find_all("img")
|
||||
missing = [img for img in images if not img.get("alt") and img.get("alt") != ""]
|
||||
|
||||
if missing:
|
||||
issues.append(self._create_issue(
|
||||
code="A-01",
|
||||
severity="critical",
|
||||
message=f"alt 속성이 없는 이미지가 {len(missing)}개 발견되었습니다",
|
||||
element=str(missing[0])[:200] if missing else None,
|
||||
suggestion="모든 이미지에 설명적인 대체 텍스트를 추가하세요",
|
||||
wcag_criterion="1.1.1",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _bs_check_form_labels(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-05: Check form elements for associated labels."""
|
||||
issues = []
|
||||
inputs = soup.find_all(["input", "select", "textarea"])
|
||||
unlabeled = []
|
||||
|
||||
for inp in inputs:
|
||||
input_type = inp.get("type", "text")
|
||||
if input_type in ("hidden", "submit", "button", "reset", "image"):
|
||||
continue
|
||||
|
||||
inp_id = inp.get("id")
|
||||
has_label = False
|
||||
|
||||
if inp_id:
|
||||
label = soup.find("label", attrs={"for": inp_id})
|
||||
if label:
|
||||
has_label = True
|
||||
|
||||
if inp.get("aria-label") or inp.get("aria-labelledby") or inp.get("title"):
|
||||
has_label = True
|
||||
|
||||
# Check if wrapped in label
|
||||
parent_label = inp.find_parent("label")
|
||||
if parent_label:
|
||||
has_label = True
|
||||
|
||||
if not has_label:
|
||||
unlabeled.append(inp)
|
||||
|
||||
if unlabeled:
|
||||
issues.append(self._create_issue(
|
||||
code="A-05",
|
||||
severity="critical",
|
||||
message=f"레이블이 연결되지 않은 폼 요소가 {len(unlabeled)}개 발견되었습니다",
|
||||
element=str(unlabeled[0])[:200] if unlabeled else None,
|
||||
suggestion="<label for='id'>를 사용하거나 aria-label 속성을 추가하세요",
|
||||
wcag_criterion="1.3.1",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _bs_check_aria(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-06: Basic ARIA attribute validation."""
|
||||
issues = []
|
||||
valid_roles = {
|
||||
"alert", "alertdialog", "application", "article", "banner", "button",
|
||||
"cell", "checkbox", "columnheader", "combobox", "complementary",
|
||||
"contentinfo", "definition", "dialog", "directory", "document",
|
||||
"feed", "figure", "form", "grid", "gridcell", "group", "heading",
|
||||
"img", "link", "list", "listbox", "listitem", "log", "main",
|
||||
"marquee", "math", "menu", "menubar", "menuitem", "menuitemcheckbox",
|
||||
"menuitemradio", "navigation", "none", "note", "option", "presentation",
|
||||
"progressbar", "radio", "radiogroup", "region", "row", "rowgroup",
|
||||
"rowheader", "scrollbar", "search", "searchbox", "separator",
|
||||
"slider", "spinbutton", "status", "switch", "tab", "table",
|
||||
"tablist", "tabpanel", "term", "textbox", "timer", "toolbar",
|
||||
"tooltip", "tree", "treegrid", "treeitem",
|
||||
}
|
||||
|
||||
elements_with_role = soup.find_all(attrs={"role": True})
|
||||
invalid_roles = []
|
||||
for el in elements_with_role:
|
||||
role = el.get("role", "").strip().lower()
|
||||
if role and role not in valid_roles:
|
||||
invalid_roles.append(el)
|
||||
|
||||
if invalid_roles:
|
||||
issues.append(self._create_issue(
|
||||
code="A-06",
|
||||
severity="major",
|
||||
message=f"유효하지 않은 ARIA 역할이 {len(invalid_roles)}개 발견되었습니다",
|
||||
element=str(invalid_roles[0])[:200] if invalid_roles else None,
|
||||
suggestion="올바른 ARIA 역할을 사용하세요 (WAI-ARIA 명세 참조)",
|
||||
wcag_criterion="4.1.2",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _bs_check_link_text(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-07: Check link text clarity."""
|
||||
issues = []
|
||||
vague_texts = {"click here", "here", "more", "read more", "link", "여기", "더보기", "클릭"}
|
||||
links = soup.find_all("a")
|
||||
vague_links = []
|
||||
|
||||
for link in links:
|
||||
text = link.get_text(strip=True).lower()
|
||||
if text in vague_texts:
|
||||
vague_links.append(link)
|
||||
|
||||
if vague_links:
|
||||
issues.append(self._create_issue(
|
||||
code="A-07",
|
||||
severity="minor",
|
||||
message=f"목적이 불분명한 링크 텍스트가 {len(vague_links)}개 발견되었습니다",
|
||||
element=str(vague_links[0])[:200] if vague_links else None,
|
||||
suggestion="'여기를 클릭하세요' 대신 구체적인 링크 목적을 설명하는 텍스트를 사용하세요",
|
||||
wcag_criterion="2.4.4",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _bs_check_lang(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-08: Check page language attribute."""
|
||||
html_tag = soup.find("html")
|
||||
if html_tag is None or not html_tag.get("lang"):
|
||||
return [self._create_issue(
|
||||
code="A-08",
|
||||
severity="major",
|
||||
message="HTML 요소에 lang 속성이 없습니다",
|
||||
suggestion='<html lang="ko">와 같이 페이지 언어를 명시하세요',
|
||||
wcag_criterion="3.1.1",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _bs_check_skip_nav(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-09: Check for skip navigation link."""
|
||||
# Look for skip nav patterns
|
||||
skip_links = soup.find_all("a", href=True)
|
||||
has_skip = False
|
||||
for link in skip_links[:10]: # Check first 10 links
|
||||
href = link.get("href", "")
|
||||
text = link.get_text(strip=True).lower()
|
||||
if href.startswith("#") and any(
|
||||
keyword in text
|
||||
for keyword in ["skip", "본문", "건너뛰기", "main", "content"]
|
||||
):
|
||||
has_skip = True
|
||||
break
|
||||
|
||||
if not has_skip:
|
||||
return [self._create_issue(
|
||||
code="A-09",
|
||||
severity="minor",
|
||||
message="건너뛰기 링크(skip navigation)가 없습니다",
|
||||
suggestion='페이지 상단에 <a href="#main-content">본문으로 건너뛰기</a> 링크를 추가하세요',
|
||||
wcag_criterion="2.4.1",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _bs_check_autoplay(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""A-10: Check for autoplay media without controls."""
|
||||
issues = []
|
||||
media = soup.find_all(["video", "audio"])
|
||||
|
||||
for el in media:
|
||||
if el.get("autoplay") is not None:
|
||||
has_controls = el.get("controls") is not None or el.get("muted") is not None
|
||||
if not has_controls:
|
||||
issues.append(self._create_issue(
|
||||
code="A-10",
|
||||
severity="major",
|
||||
message="자동 재생 미디어에 정지/음소거 컨트롤이 없습니다",
|
||||
element=str(el)[:200],
|
||||
suggestion="autoplay 미디어에 controls 속성을 추가하거나 muted 속성을 사용하세요",
|
||||
wcag_criterion="1.4.2",
|
||||
))
|
||||
break # Report only first
|
||||
|
||||
return issues
|
||||
12
backend/app/engines/axe_core/axe.min.js
vendored
Normal file
12
backend/app/engines/axe_core/axe.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
108
backend/app/engines/base.py
Normal file
108
backend/app/engines/base.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""
|
||||
BaseChecker abstract class - foundation for all inspection engines.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
|
||||
from app.models.schemas import CategoryResult, Issue, Severity, calculate_grade
|
||||
|
||||
|
||||
class BaseChecker(ABC):
|
||||
"""
|
||||
Abstract base class for all inspection engines.
|
||||
Provides progress callback mechanism and common utility methods.
|
||||
"""
|
||||
|
||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
async def update_progress(self, progress: int, current_step: str) -> None:
|
||||
"""Update progress via Redis callback."""
|
||||
if self.progress_callback:
|
||||
await self.progress_callback(
|
||||
category=self.category_name,
|
||||
progress=progress,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def category_name(self) -> str:
|
||||
"""Category identifier (e.g., 'html_css')."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
||||
"""Execute inspection and return results."""
|
||||
pass
|
||||
|
||||
def _create_issue(
|
||||
self,
|
||||
code: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
suggestion: str,
|
||||
element: Optional[str] = None,
|
||||
line: Optional[int] = None,
|
||||
wcag_criterion: Optional[str] = None,
|
||||
) -> Issue:
|
||||
"""Helper to create a standardized Issue object."""
|
||||
return Issue(
|
||||
code=code,
|
||||
category=self.category_name,
|
||||
severity=Severity(severity),
|
||||
message=message,
|
||||
element=element,
|
||||
line=line,
|
||||
suggestion=suggestion,
|
||||
wcag_criterion=wcag_criterion,
|
||||
)
|
||||
|
||||
def _calculate_score_by_deduction(self, issues: list[Issue]) -> int:
|
||||
"""
|
||||
Calculate score by deduction:
|
||||
score = 100 - (Critical*15 + Major*8 + Minor*3 + Info*1)
|
||||
Minimum 0, Maximum 100
|
||||
"""
|
||||
severity_weights = {
|
||||
"critical": 15,
|
||||
"major": 8,
|
||||
"minor": 3,
|
||||
"info": 1,
|
||||
}
|
||||
deduction = sum(
|
||||
severity_weights.get(issue.severity.value, 0) for issue in issues
|
||||
)
|
||||
return max(0, 100 - deduction)
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
category: str,
|
||||
score: int,
|
||||
issues: list[Issue],
|
||||
wcag_level: Optional[str] = None,
|
||||
meta_info: Optional[dict] = None,
|
||||
sub_scores: Optional[dict] = None,
|
||||
metrics: Optional[dict] = None,
|
||||
) -> CategoryResult:
|
||||
"""Build a CategoryResult with computed severity counts."""
|
||||
critical = sum(1 for i in issues if i.severity == Severity.CRITICAL)
|
||||
major = sum(1 for i in issues if i.severity == Severity.MAJOR)
|
||||
minor = sum(1 for i in issues if i.severity == Severity.MINOR)
|
||||
info = sum(1 for i in issues if i.severity == Severity.INFO)
|
||||
|
||||
return CategoryResult(
|
||||
score=score,
|
||||
grade=calculate_grade(score),
|
||||
total_issues=len(issues),
|
||||
critical=critical,
|
||||
major=major,
|
||||
minor=minor,
|
||||
info=info,
|
||||
issues=issues,
|
||||
wcag_level=wcag_level,
|
||||
meta_info=meta_info,
|
||||
sub_scores=sub_scores,
|
||||
metrics=metrics,
|
||||
)
|
||||
308
backend/app/engines/html_css.py
Normal file
308
backend/app/engines/html_css.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""
|
||||
HTML/CSS Standards Checker Engine (F-002).
|
||||
Checks HTML5 validity, semantic tags, CSS inline usage, etc.
|
||||
Uses BeautifulSoup4 + html5lib for parsing.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from collections import Counter
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.engines.base import BaseChecker
|
||||
from app.models.schemas import CategoryResult, Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEPRECATED_TAGS = [
|
||||
"font", "center", "marquee", "blink", "strike", "big", "tt",
|
||||
"basefont", "applet", "dir", "isindex",
|
||||
]
|
||||
|
||||
SEMANTIC_TAGS = ["header", "nav", "main", "footer", "section", "article"]
|
||||
|
||||
|
||||
class HtmlCssChecker(BaseChecker):
|
||||
"""HTML/CSS standards checker engine."""
|
||||
|
||||
@property
|
||||
def category_name(self) -> str:
|
||||
return "html_css"
|
||||
|
||||
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
||||
soup = BeautifulSoup(html_content, "html5lib")
|
||||
issues: list[Issue] = []
|
||||
|
||||
await self.update_progress(10, "DOCTYPE 검사 중...")
|
||||
issues += self._check_doctype(html_content)
|
||||
|
||||
await self.update_progress(20, "문자 인코딩 검사 중...")
|
||||
issues += self._check_charset(soup)
|
||||
|
||||
await self.update_progress(30, "언어 속성 검사 중...")
|
||||
issues += self._check_lang(soup)
|
||||
|
||||
await self.update_progress(40, "title 태그 검사 중...")
|
||||
issues += self._check_title(soup)
|
||||
|
||||
await self.update_progress(50, "시맨틱 태그 검사 중...")
|
||||
issues += self._check_semantic_tags(soup)
|
||||
|
||||
await self.update_progress(60, "이미지 alt 속성 검사 중...")
|
||||
issues += self._check_img_alt(soup)
|
||||
|
||||
await self.update_progress(70, "중복 ID 검사 중...")
|
||||
issues += self._check_duplicate_ids(soup)
|
||||
|
||||
await self.update_progress(80, "링크 및 스타일 검사 중...")
|
||||
issues += self._check_empty_links(soup)
|
||||
issues += self._check_inline_styles(soup)
|
||||
issues += self._check_deprecated_tags(soup)
|
||||
|
||||
await self.update_progress(90, "heading 구조 검사 중...")
|
||||
issues += self._check_heading_hierarchy(soup)
|
||||
issues += self._check_viewport_meta(soup)
|
||||
|
||||
score = self._calculate_score_by_deduction(issues)
|
||||
await self.update_progress(100, "완료")
|
||||
|
||||
return self._build_result(
|
||||
category="html_css",
|
||||
score=score,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
def _check_doctype(self, html_content: str) -> list[Issue]:
|
||||
"""H-01: Check for <!DOCTYPE html> declaration."""
|
||||
stripped = html_content.lstrip()
|
||||
if not stripped.lower().startswith("<!doctype html"):
|
||||
return [self._create_issue(
|
||||
code="H-01",
|
||||
severity="major",
|
||||
message="DOCTYPE 선언이 없습니다",
|
||||
suggestion="문서 최상단에 <!DOCTYPE html>을 추가하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_charset(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-02: Check for <meta charset='utf-8'>."""
|
||||
meta_charset = soup.find("meta", attrs={"charset": True})
|
||||
meta_content_type = soup.find("meta", attrs={"http-equiv": re.compile(r"content-type", re.I)})
|
||||
|
||||
if meta_charset is None and meta_content_type is None:
|
||||
return [self._create_issue(
|
||||
code="H-02",
|
||||
severity="major",
|
||||
message="문자 인코딩(charset) 선언이 없습니다",
|
||||
suggestion='<meta charset="utf-8">을 <head> 태그 안에 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_lang(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-03: Check for <html lang='...'> attribute."""
|
||||
html_tag = soup.find("html")
|
||||
if html_tag is None or not html_tag.get("lang"):
|
||||
return [self._create_issue(
|
||||
code="H-03",
|
||||
severity="minor",
|
||||
message="HTML 언어 속성(lang)이 설정되지 않았습니다",
|
||||
suggestion='<html lang="ko"> 또는 해당 언어 코드를 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_title(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-04: Check for <title> tag existence and content."""
|
||||
title = soup.find("title")
|
||||
if title is None:
|
||||
return [self._create_issue(
|
||||
code="H-04",
|
||||
severity="major",
|
||||
message="<title> 태그가 없습니다",
|
||||
suggestion="<head> 안에 <title> 태그를 추가하세요",
|
||||
)]
|
||||
if title.string is None or title.string.strip() == "":
|
||||
return [self._create_issue(
|
||||
code="H-04",
|
||||
severity="major",
|
||||
message="<title> 태그가 비어있습니다",
|
||||
element=str(title),
|
||||
suggestion="<title> 태그에 페이지 제목을 입력하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_semantic_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-05: Check for semantic HTML5 tag usage."""
|
||||
found_tags = set()
|
||||
for tag_name in SEMANTIC_TAGS:
|
||||
if soup.find(tag_name):
|
||||
found_tags.add(tag_name)
|
||||
|
||||
if not found_tags:
|
||||
return [self._create_issue(
|
||||
code="H-05",
|
||||
severity="minor",
|
||||
message="시맨틱 태그가 사용되지 않았습니다 (header, nav, main, footer, section, article)",
|
||||
suggestion="적절한 시맨틱 태그를 사용하여 문서 구조를 명확히 하세요",
|
||||
)]
|
||||
|
||||
missing = set(SEMANTIC_TAGS) - found_tags
|
||||
# Only report if major structural elements are missing (main is most important)
|
||||
if "main" in missing:
|
||||
return [self._create_issue(
|
||||
code="H-05",
|
||||
severity="minor",
|
||||
message=f"주요 시맨틱 태그가 누락되었습니다: {', '.join(sorted(missing))}",
|
||||
suggestion="<main> 태그를 사용하여 주요 콘텐츠 영역을 표시하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_img_alt(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-06: Check all <img> tags have alt attributes."""
|
||||
issues = []
|
||||
images = soup.find_all("img")
|
||||
for img in images:
|
||||
if not img.get("alt") and img.get("alt") != "":
|
||||
line = self._get_line_number(img)
|
||||
issues.append(self._create_issue(
|
||||
code="H-06",
|
||||
severity="major",
|
||||
message="이미지에 alt 속성이 없습니다",
|
||||
element=self._truncate_element(str(img)),
|
||||
line=line,
|
||||
suggestion="이미지에 설명을 위한 alt 속성을 추가하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_duplicate_ids(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-07: Check for duplicate ID attributes."""
|
||||
issues = []
|
||||
id_elements = soup.find_all(id=True)
|
||||
id_counter = Counter(el.get("id") for el in id_elements)
|
||||
|
||||
for id_val, count in id_counter.items():
|
||||
if count > 1:
|
||||
elements = [el for el in id_elements if el.get("id") == id_val]
|
||||
first_el = elements[0] if elements else None
|
||||
line = self._get_line_number(first_el) if first_el else None
|
||||
issues.append(self._create_issue(
|
||||
code="H-07",
|
||||
severity="critical",
|
||||
message=f"중복 ID 발견: '{id_val}' ({count}회 사용)",
|
||||
element=self._truncate_element(str(first_el)) if first_el else None,
|
||||
line=line,
|
||||
suggestion="각 요소에 고유한 ID를 부여하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_empty_links(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-08: Check for empty or '#' href links."""
|
||||
issues = []
|
||||
links = soup.find_all("a")
|
||||
empty_count = 0
|
||||
first_element = None
|
||||
first_line = None
|
||||
|
||||
for link in links:
|
||||
href = link.get("href", "")
|
||||
if href == "" or href == "#":
|
||||
empty_count += 1
|
||||
if first_element is None:
|
||||
first_element = self._truncate_element(str(link))
|
||||
first_line = self._get_line_number(link)
|
||||
|
||||
if empty_count > 0:
|
||||
issues.append(self._create_issue(
|
||||
code="H-08",
|
||||
severity="minor",
|
||||
message=f"빈 링크(href가 비어있거나 '#')가 {empty_count}개 발견되었습니다",
|
||||
element=first_element,
|
||||
line=first_line,
|
||||
suggestion="링크에 유효한 URL을 설정하거나, 버튼이 필요한 경우 <button>을 사용하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_inline_styles(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-09: Check for inline style attributes."""
|
||||
issues = []
|
||||
styled_elements = soup.find_all(style=True)
|
||||
|
||||
if styled_elements:
|
||||
first_el = styled_elements[0]
|
||||
issues.append(self._create_issue(
|
||||
code="H-09",
|
||||
severity="info",
|
||||
message=f"인라인 스타일이 {len(styled_elements)}개 요소에서 사용되고 있습니다",
|
||||
element=self._truncate_element(str(first_el)),
|
||||
line=self._get_line_number(first_el),
|
||||
suggestion="인라인 스타일 대신 외부 CSS 파일 또는 <style> 태그를 사용하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_deprecated_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-10: Check for deprecated HTML tags."""
|
||||
issues = []
|
||||
for tag_name in DEPRECATED_TAGS:
|
||||
found = soup.find_all(tag_name)
|
||||
if found:
|
||||
first_el = found[0]
|
||||
issues.append(self._create_issue(
|
||||
code="H-10",
|
||||
severity="major",
|
||||
message=f"사용 중단된(deprecated) 태그 <{tag_name}>이(가) {len(found)}회 사용되었습니다",
|
||||
element=self._truncate_element(str(first_el)),
|
||||
line=self._get_line_number(first_el),
|
||||
suggestion=f"<{tag_name}> 대신 CSS를 사용하여 스타일을 적용하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_heading_hierarchy(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-11: Check heading hierarchy (h1-h6 should not skip levels)."""
|
||||
issues = []
|
||||
headings = soup.find_all(re.compile(r"^h[1-6]$"))
|
||||
|
||||
if not headings:
|
||||
return []
|
||||
|
||||
prev_level = 0
|
||||
for heading in headings:
|
||||
level = int(heading.name[1])
|
||||
if prev_level > 0 and level > prev_level + 1:
|
||||
issues.append(self._create_issue(
|
||||
code="H-11",
|
||||
severity="minor",
|
||||
message=f"heading 계층 구조가 건너뛰어졌습니다: h{prev_level} 다음에 h{level}",
|
||||
element=self._truncate_element(str(heading)),
|
||||
line=self._get_line_number(heading),
|
||||
suggestion=f"h{prev_level} 다음에는 h{prev_level + 1}을 사용하세요",
|
||||
))
|
||||
break # Only report first skip
|
||||
prev_level = level
|
||||
return issues
|
||||
|
||||
def _check_viewport_meta(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-12: Check for viewport meta tag."""
|
||||
viewport = soup.find("meta", attrs={"name": re.compile(r"viewport", re.I)})
|
||||
if viewport is None:
|
||||
return [self._create_issue(
|
||||
code="H-12",
|
||||
severity="major",
|
||||
message="viewport meta 태그가 없습니다",
|
||||
suggestion='<meta name="viewport" content="width=device-width, initial-scale=1.0">을 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_line_number(element) -> Optional[int]:
|
||||
"""Extract source line number from a BeautifulSoup element."""
|
||||
if element and hasattr(element, "sourceline"):
|
||||
return element.sourceline
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _truncate_element(element_str: str, max_len: int = 200) -> str:
|
||||
"""Truncate element string for display."""
|
||||
if len(element_str) > max_len:
|
||||
return element_str[:max_len] + "..."
|
||||
return element_str
|
||||
454
backend/app/engines/performance_security.py
Normal file
454
backend/app/engines/performance_security.py
Normal file
@ -0,0 +1,454 @@
|
||||
"""
|
||||
Performance/Security Checker Engine (F-005).
|
||||
Checks security headers, HTTPS, SSL certificate, response time, page size, etc.
|
||||
"""
|
||||
|
||||
import re
|
||||
import ssl
|
||||
import socket
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.engines.base import BaseChecker
|
||||
from app.models.schemas import CategoryResult, Issue, calculate_grade
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerformanceSecurityChecker(BaseChecker):
|
||||
"""Performance and security checker engine."""
|
||||
|
||||
@property
|
||||
def category_name(self) -> str:
|
||||
return "performance_security"
|
||||
|
||||
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
||||
issues: list[Issue] = []
|
||||
metrics: dict = {}
|
||||
|
||||
await self.update_progress(10, "HTTPS 검사 중...")
|
||||
issues += self._check_https(url, metrics)
|
||||
|
||||
await self.update_progress(20, "SSL 인증서 검사 중...")
|
||||
issues += await self._check_ssl(url, metrics)
|
||||
|
||||
await self.update_progress(35, "보안 헤더 검사 중...")
|
||||
issues += self._check_hsts(headers)
|
||||
issues += self._check_csp(headers)
|
||||
issues += self._check_x_content_type(headers)
|
||||
issues += self._check_x_frame_options(headers)
|
||||
issues += self._check_x_xss_protection(headers)
|
||||
issues += self._check_referrer_policy(headers)
|
||||
issues += self._check_permissions_policy(headers)
|
||||
|
||||
await self.update_progress(60, "응답 시간 측정 중...")
|
||||
issues += await self._check_ttfb(url, metrics)
|
||||
|
||||
await self.update_progress(70, "페이지 크기 분석 중...")
|
||||
issues += self._check_page_size(html_content, metrics)
|
||||
|
||||
await self.update_progress(80, "리다이렉트 검사 중...")
|
||||
issues += await self._check_redirects(url, metrics)
|
||||
|
||||
await self.update_progress(85, "압축 검사 중...")
|
||||
issues += self._check_compression(headers, metrics)
|
||||
|
||||
await self.update_progress(90, "혼합 콘텐츠 검사 중...")
|
||||
issues += self._check_mixed_content(url, html_content)
|
||||
|
||||
score, sub_scores = self._calculate_composite_score(issues, metrics)
|
||||
await self.update_progress(100, "완료")
|
||||
|
||||
return self._build_result(
|
||||
category="performance_security",
|
||||
score=score,
|
||||
issues=issues,
|
||||
sub_scores=sub_scores,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
def _check_https(self, url: str, metrics: dict) -> list[Issue]:
|
||||
"""P-01: Check HTTPS usage."""
|
||||
parsed = urlparse(url)
|
||||
is_https = parsed.scheme == "https"
|
||||
metrics["https"] = is_https
|
||||
|
||||
if not is_https:
|
||||
return [self._create_issue(
|
||||
code="P-01",
|
||||
severity="critical",
|
||||
message="HTTPS를 사용하지 않고 있습니다",
|
||||
suggestion="사이트 보안을 위해 HTTPS를 적용하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
async def _check_ssl(self, url: str, metrics: dict) -> list[Issue]:
|
||||
"""P-02: Check SSL certificate validity and expiry."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
metrics["ssl_valid"] = False
|
||||
metrics["ssl_expiry_days"] = None
|
||||
return [self._create_issue(
|
||||
code="P-02",
|
||||
severity="critical",
|
||||
message="HTTPS를 사용하지 않아 SSL 인증서를 확인할 수 없습니다",
|
||||
suggestion="SSL 인증서를 설치하고 HTTPS를 적용하세요",
|
||||
)]
|
||||
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or 443
|
||||
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
conn = ctx.wrap_socket(
|
||||
socket.socket(socket.AF_INET),
|
||||
server_hostname=hostname,
|
||||
)
|
||||
conn.settimeout(5)
|
||||
conn.connect((hostname, port))
|
||||
cert = conn.getpeercert()
|
||||
conn.close()
|
||||
|
||||
# Check expiry
|
||||
not_after = cert.get("notAfter")
|
||||
if not_after:
|
||||
expiry_date = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
|
||||
days_remaining = (expiry_date - datetime.now()).days
|
||||
metrics["ssl_valid"] = True
|
||||
metrics["ssl_expiry_days"] = days_remaining
|
||||
|
||||
if days_remaining < 0:
|
||||
return [self._create_issue(
|
||||
code="P-02",
|
||||
severity="critical",
|
||||
message="SSL 인증서가 만료되었습니다",
|
||||
suggestion="SSL 인증서를 즉시 갱신하세요",
|
||||
)]
|
||||
elif days_remaining < 30:
|
||||
return [self._create_issue(
|
||||
code="P-02",
|
||||
severity="major",
|
||||
message=f"SSL 인증서가 {days_remaining}일 후 만료됩니다",
|
||||
suggestion="인증서 만료 전에 갱신하세요",
|
||||
)]
|
||||
else:
|
||||
metrics["ssl_valid"] = True
|
||||
metrics["ssl_expiry_days"] = None
|
||||
|
||||
except ssl.SSLError as e:
|
||||
metrics["ssl_valid"] = False
|
||||
metrics["ssl_expiry_days"] = None
|
||||
return [self._create_issue(
|
||||
code="P-02",
|
||||
severity="critical",
|
||||
message=f"SSL 인증서가 유효하지 않습니다: {str(e)[:100]}",
|
||||
suggestion="유효한 SSL 인증서를 설치하세요",
|
||||
)]
|
||||
except Exception as e:
|
||||
logger.warning("SSL check failed for %s: %s", url, str(e))
|
||||
metrics["ssl_valid"] = None
|
||||
metrics["ssl_expiry_days"] = None
|
||||
return [self._create_issue(
|
||||
code="P-02",
|
||||
severity="minor",
|
||||
message="SSL 인증서를 확인할 수 없습니다",
|
||||
suggestion="서버의 SSL 설정을 점검하세요",
|
||||
)]
|
||||
|
||||
return []
|
||||
|
||||
def _check_hsts(self, headers: dict) -> list[Issue]:
|
||||
"""P-03: Check Strict-Transport-Security header."""
|
||||
hsts = self._get_header(headers, "Strict-Transport-Security")
|
||||
if not hsts:
|
||||
return [self._create_issue(
|
||||
code="P-03",
|
||||
severity="major",
|
||||
message="Strict-Transport-Security(HSTS) 헤더가 설정되지 않았습니다",
|
||||
suggestion="HSTS 헤더를 추가하세요: Strict-Transport-Security: max-age=31536000; includeSubDomains",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_csp(self, headers: dict) -> list[Issue]:
|
||||
"""P-04: Check Content-Security-Policy header."""
|
||||
csp = self._get_header(headers, "Content-Security-Policy")
|
||||
if not csp:
|
||||
return [self._create_issue(
|
||||
code="P-04",
|
||||
severity="major",
|
||||
message="Content-Security-Policy(CSP) 헤더가 설정되지 않았습니다",
|
||||
suggestion="CSP 헤더를 추가하여 XSS 공격을 방지하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_x_content_type(self, headers: dict) -> list[Issue]:
|
||||
"""P-05: Check X-Content-Type-Options header."""
|
||||
xcto = self._get_header(headers, "X-Content-Type-Options")
|
||||
if not xcto or "nosniff" not in xcto.lower():
|
||||
return [self._create_issue(
|
||||
code="P-05",
|
||||
severity="minor",
|
||||
message="X-Content-Type-Options 헤더가 설정되지 않았습니다",
|
||||
suggestion="X-Content-Type-Options: nosniff 헤더를 추가하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_x_frame_options(self, headers: dict) -> list[Issue]:
|
||||
"""P-06: Check X-Frame-Options header."""
|
||||
xfo = self._get_header(headers, "X-Frame-Options")
|
||||
if not xfo:
|
||||
return [self._create_issue(
|
||||
code="P-06",
|
||||
severity="minor",
|
||||
message="X-Frame-Options 헤더가 설정되지 않았습니다",
|
||||
suggestion="클릭재킹 방지를 위해 X-Frame-Options: DENY 또는 SAMEORIGIN을 설정하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_x_xss_protection(self, headers: dict) -> list[Issue]:
|
||||
"""P-07: Check X-XSS-Protection header (deprecated notice)."""
|
||||
xxp = self._get_header(headers, "X-XSS-Protection")
|
||||
if xxp:
|
||||
return [self._create_issue(
|
||||
code="P-07",
|
||||
severity="info",
|
||||
message="X-XSS-Protection 헤더가 설정되어 있습니다 (현재 deprecated)",
|
||||
suggestion="X-XSS-Protection 대신 Content-Security-Policy를 사용하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_referrer_policy(self, headers: dict) -> list[Issue]:
|
||||
"""P-08: Check Referrer-Policy header."""
|
||||
rp = self._get_header(headers, "Referrer-Policy")
|
||||
if not rp:
|
||||
return [self._create_issue(
|
||||
code="P-08",
|
||||
severity="minor",
|
||||
message="Referrer-Policy 헤더가 설정되지 않았습니다",
|
||||
suggestion="Referrer-Policy: strict-origin-when-cross-origin을 설정하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_permissions_policy(self, headers: dict) -> list[Issue]:
|
||||
"""P-09: Check Permissions-Policy header."""
|
||||
pp = self._get_header(headers, "Permissions-Policy")
|
||||
if not pp:
|
||||
return [self._create_issue(
|
||||
code="P-09",
|
||||
severity="minor",
|
||||
message="Permissions-Policy 헤더가 설정되지 않았습니다",
|
||||
suggestion="Permissions-Policy 헤더를 추가하여 브라우저 기능 접근을 제한하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
async def _check_ttfb(self, url: str, metrics: dict) -> list[Issue]:
|
||||
"""P-10: Check Time To First Byte (TTFB)."""
|
||||
try:
|
||||
start = time.monotonic()
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0),
|
||||
follow_redirects=True,
|
||||
verify=False,
|
||||
) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "WebInspector/1.0 (Inspection Bot)",
|
||||
})
|
||||
ttfb_ms = round((time.monotonic() - start) * 1000)
|
||||
metrics["ttfb_ms"] = ttfb_ms
|
||||
|
||||
if ttfb_ms > 2000:
|
||||
return [self._create_issue(
|
||||
code="P-10",
|
||||
severity="major",
|
||||
message=f"응답 시간(TTFB)이 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
|
||||
suggestion="서버 응답 속도를 개선하세요 (캐싱, CDN, 서버 최적화)",
|
||||
)]
|
||||
elif ttfb_ms > 1000:
|
||||
return [self._create_issue(
|
||||
code="P-10",
|
||||
severity="minor",
|
||||
message=f"응답 시간(TTFB)이 다소 느립니다: {ttfb_ms}ms (권장 < 1000ms)",
|
||||
suggestion="서버 응답 속도 개선을 고려하세요",
|
||||
)]
|
||||
except Exception as e:
|
||||
logger.warning("TTFB check failed for %s: %s", url, str(e))
|
||||
metrics["ttfb_ms"] = None
|
||||
return [self._create_issue(
|
||||
code="P-10",
|
||||
severity="major",
|
||||
message="응답 시간(TTFB)을 측정할 수 없습니다",
|
||||
suggestion="서버 접근성을 확인하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_page_size(self, html_content: str, metrics: dict) -> list[Issue]:
|
||||
"""P-11: Check HTML page size."""
|
||||
size_bytes = len(html_content.encode("utf-8"))
|
||||
metrics["page_size_bytes"] = size_bytes
|
||||
|
||||
if size_bytes > 3 * 1024 * 1024: # 3MB
|
||||
return [self._create_issue(
|
||||
code="P-11",
|
||||
severity="minor",
|
||||
message=f"페이지 크기가 큽니다: {round(size_bytes / 1024 / 1024, 1)}MB (권장 < 3MB)",
|
||||
suggestion="페이지 크기를 줄이세요 (불필요한 코드 제거, 이미지 최적화, 코드 분할)",
|
||||
)]
|
||||
return []
|
||||
|
||||
async def _check_redirects(self, url: str, metrics: dict) -> list[Issue]:
|
||||
"""P-12: Check redirect chain length."""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0),
|
||||
follow_redirects=True,
|
||||
verify=False,
|
||||
) as client:
|
||||
resp = await client.get(url, headers={
|
||||
"User-Agent": "WebInspector/1.0 (Inspection Bot)",
|
||||
})
|
||||
redirect_count = len(resp.history)
|
||||
metrics["redirect_count"] = redirect_count
|
||||
|
||||
if redirect_count >= 3:
|
||||
return [self._create_issue(
|
||||
code="P-12",
|
||||
severity="minor",
|
||||
message=f"리다이렉트가 {redirect_count}회 발생합니다 (권장 < 3회)",
|
||||
suggestion="리다이렉트 체인을 줄여 로딩 속도를 개선하세요",
|
||||
)]
|
||||
except Exception as e:
|
||||
logger.warning("Redirect check failed for %s: %s", url, str(e))
|
||||
metrics["redirect_count"] = None
|
||||
return []
|
||||
|
||||
def _check_compression(self, headers: dict, metrics: dict) -> list[Issue]:
|
||||
"""P-13: Check response compression (Gzip/Brotli)."""
|
||||
encoding = self._get_header(headers, "Content-Encoding")
|
||||
if encoding:
|
||||
metrics["compression"] = encoding.lower()
|
||||
return []
|
||||
|
||||
metrics["compression"] = None
|
||||
return [self._create_issue(
|
||||
code="P-13",
|
||||
severity="minor",
|
||||
message="응답 압축(Gzip/Brotli)이 적용되지 않았습니다",
|
||||
suggestion="서버에서 Gzip 또는 Brotli 압축을 활성화하세요",
|
||||
)]
|
||||
|
||||
def _check_mixed_content(self, url: str, html_content: str) -> list[Issue]:
|
||||
"""P-14: Check for mixed content (HTTP resources on HTTPS page)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html_content, "html5lib")
|
||||
mixed_elements = []
|
||||
|
||||
# Check src attributes
|
||||
for tag in soup.find_all(["img", "script", "link", "iframe", "audio", "video", "source"]):
|
||||
src = tag.get("src") or tag.get("href")
|
||||
if src and src.startswith("http://"):
|
||||
mixed_elements.append(tag)
|
||||
|
||||
if mixed_elements:
|
||||
return [self._create_issue(
|
||||
code="P-14",
|
||||
severity="major",
|
||||
message=f"혼합 콘텐츠 발견: HTTPS 페이지에서 HTTP 리소스 {len(mixed_elements)}개 로드",
|
||||
element=self._truncate_element(str(mixed_elements[0])) if mixed_elements else None,
|
||||
suggestion="모든 리소스를 HTTPS로 변경하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _calculate_composite_score(self, issues: list[Issue], metrics: dict) -> tuple[int, dict]:
|
||||
"""
|
||||
Calculate composite score:
|
||||
Security (70%): HTTPS/SSL (30%) + Security Headers (40%)
|
||||
Performance (30%): Response time (40%) + Page size (30%) + Compression (30%)
|
||||
"""
|
||||
# Security score
|
||||
security_score = 100
|
||||
|
||||
# HTTPS/SSL component (30% of security)
|
||||
https_ssl_score = 100
|
||||
for issue in issues:
|
||||
if issue.code in ("P-01", "P-02"):
|
||||
if issue.severity.value == "critical":
|
||||
https_ssl_score -= 50
|
||||
elif issue.severity.value == "major":
|
||||
https_ssl_score -= 25
|
||||
https_ssl_score = max(0, https_ssl_score)
|
||||
|
||||
# Security headers component (40% of security)
|
||||
header_issues = [i for i in issues if i.code in ("P-03", "P-04", "P-05", "P-06", "P-07", "P-08", "P-09")]
|
||||
total_header_checks = 7
|
||||
passed_headers = total_header_checks - len(header_issues)
|
||||
header_score = round(passed_headers / total_header_checks * 100) if total_header_checks else 100
|
||||
|
||||
security_score = round(https_ssl_score * 0.43 + header_score * 0.57)
|
||||
|
||||
# Performance score
|
||||
perf_score = 100
|
||||
|
||||
# TTFB component (40% of performance)
|
||||
ttfb = metrics.get("ttfb_ms")
|
||||
if ttfb is not None:
|
||||
if ttfb <= 500:
|
||||
ttfb_score = 100
|
||||
elif ttfb <= 1000:
|
||||
ttfb_score = 80
|
||||
elif ttfb <= 2000:
|
||||
ttfb_score = 60
|
||||
else:
|
||||
ttfb_score = 30
|
||||
else:
|
||||
ttfb_score = 50
|
||||
|
||||
# Page size component (30% of performance)
|
||||
page_size = metrics.get("page_size_bytes", 0)
|
||||
if page_size <= 1024 * 1024: # 1MB
|
||||
size_score = 100
|
||||
elif page_size <= 2 * 1024 * 1024: # 2MB
|
||||
size_score = 80
|
||||
elif page_size <= 3 * 1024 * 1024: # 3MB
|
||||
size_score = 60
|
||||
else:
|
||||
size_score = 30
|
||||
|
||||
# Compression component (30% of performance)
|
||||
compression = metrics.get("compression")
|
||||
compression_score = 100 if compression else 50
|
||||
|
||||
perf_score = round(ttfb_score * 0.4 + size_score * 0.3 + compression_score * 0.3)
|
||||
|
||||
# Composite
|
||||
overall = round(security_score * 0.7 + perf_score * 0.3)
|
||||
overall = max(0, min(100, overall))
|
||||
|
||||
sub_scores = {
|
||||
"security": security_score,
|
||||
"performance": perf_score,
|
||||
}
|
||||
|
||||
return overall, sub_scores
|
||||
|
||||
@staticmethod
|
||||
def _get_header(headers: dict, name: str) -> Optional[str]:
|
||||
"""Case-insensitive header lookup."""
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _truncate_element(element_str: str, max_len: int = 200) -> str:
|
||||
if len(element_str) > max_len:
|
||||
return element_str[:max_len] + "..."
|
||||
return element_str
|
||||
382
backend/app/engines/seo.py
Normal file
382
backend/app/engines/seo.py
Normal file
@ -0,0 +1,382 @@
|
||||
"""
|
||||
SEO Optimization Checker Engine (F-004).
|
||||
Checks meta tags, OG tags, robots.txt, sitemap.xml, structured data, etc.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.engines.base import BaseChecker
|
||||
from app.models.schemas import CategoryResult, Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SeoChecker(BaseChecker):
|
||||
"""SEO optimization checker engine."""
|
||||
|
||||
@property
|
||||
def category_name(self) -> str:
|
||||
return "seo"
|
||||
|
||||
async def check(self, url: str, html_content: str, headers: dict) -> CategoryResult:
|
||||
soup = BeautifulSoup(html_content, "html5lib")
|
||||
issues: list[Issue] = []
|
||||
meta_info: dict = {}
|
||||
|
||||
await self.update_progress(10, "title 태그 검사 중...")
|
||||
issues += self._check_title(soup, meta_info)
|
||||
|
||||
await self.update_progress(20, "meta description 검사 중...")
|
||||
issues += self._check_meta_description(soup, meta_info)
|
||||
issues += self._check_meta_keywords(soup, meta_info)
|
||||
|
||||
await self.update_progress(30, "OG 태그 검사 중...")
|
||||
issues += self._check_og_tags(soup)
|
||||
issues += self._check_twitter_card(soup)
|
||||
|
||||
await self.update_progress(40, "canonical URL 검사 중...")
|
||||
issues += self._check_canonical(soup)
|
||||
|
||||
await self.update_progress(50, "robots.txt 확인 중...")
|
||||
issues += await self._check_robots_txt(url, meta_info)
|
||||
|
||||
await self.update_progress(60, "sitemap.xml 확인 중...")
|
||||
issues += await self._check_sitemap(url, meta_info)
|
||||
|
||||
await self.update_progress(70, "H1 태그 검사 중...")
|
||||
issues += self._check_h1(soup)
|
||||
|
||||
await self.update_progress(80, "구조화 데이터 검사 중...")
|
||||
issues += self._check_structured_data(soup, html_content, meta_info)
|
||||
|
||||
await self.update_progress(90, "기타 항목 검사 중...")
|
||||
issues += self._check_favicon(soup)
|
||||
issues += self._check_viewport(soup)
|
||||
issues += self._check_url_structure(url)
|
||||
issues += self._check_img_alt_seo(soup)
|
||||
|
||||
score = self._calculate_score_by_deduction(issues)
|
||||
await self.update_progress(100, "완료")
|
||||
|
||||
return self._build_result(
|
||||
category="seo",
|
||||
score=score,
|
||||
issues=issues,
|
||||
meta_info=meta_info,
|
||||
)
|
||||
|
||||
def _check_title(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
||||
"""S-01: Check title tag existence and length (10-60 chars)."""
|
||||
issues = []
|
||||
title = soup.find("title")
|
||||
|
||||
if title is None or not title.string or title.string.strip() == "":
|
||||
meta_info["title"] = None
|
||||
meta_info["title_length"] = 0
|
||||
issues.append(self._create_issue(
|
||||
code="S-01",
|
||||
severity="critical",
|
||||
message="<title> 태그가 없거나 비어있습니다",
|
||||
suggestion="검색 결과에 표시될 10-60자 길이의 페이지 제목을 설정하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
title_text = title.string.strip()
|
||||
title_len = len(title_text)
|
||||
meta_info["title"] = title_text
|
||||
meta_info["title_length"] = title_len
|
||||
|
||||
if title_len < 10:
|
||||
issues.append(self._create_issue(
|
||||
code="S-01",
|
||||
severity="critical",
|
||||
message=f"title이 너무 짧습니다 ({title_len}자, 권장 10-60자)",
|
||||
element=f"<title>{title_text}</title>",
|
||||
suggestion="검색 결과에 효과적으로 표시되도록 10자 이상의 제목을 작성하세요",
|
||||
))
|
||||
elif title_len > 60:
|
||||
issues.append(self._create_issue(
|
||||
code="S-01",
|
||||
severity="minor",
|
||||
message=f"title이 너무 깁니다 ({title_len}자, 권장 10-60자)",
|
||||
element=f"<title>{title_text[:50]}...</title>",
|
||||
suggestion="검색 결과에서 잘리지 않도록 60자 이내로 제목을 줄이세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_meta_description(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
||||
"""S-02: Check meta description existence and length (50-160 chars)."""
|
||||
issues = []
|
||||
desc = soup.find("meta", attrs={"name": re.compile(r"^description$", re.I)})
|
||||
|
||||
if desc is None or not desc.get("content"):
|
||||
meta_info["description"] = None
|
||||
meta_info["description_length"] = 0
|
||||
issues.append(self._create_issue(
|
||||
code="S-02",
|
||||
severity="major",
|
||||
message="meta description이 없습니다",
|
||||
suggestion='<meta name="description" content="페이지 설명">을 추가하세요 (50-160자 권장)',
|
||||
))
|
||||
return issues
|
||||
|
||||
content = desc["content"].strip()
|
||||
content_len = len(content)
|
||||
meta_info["description"] = content
|
||||
meta_info["description_length"] = content_len
|
||||
|
||||
if content_len < 50:
|
||||
issues.append(self._create_issue(
|
||||
code="S-02",
|
||||
severity="major",
|
||||
message=f"meta description이 너무 짧습니다 ({content_len}자, 권장 50-160자)",
|
||||
suggestion="검색 결과에서 페이지를 효과적으로 설명하도록 50자 이상으로 작성하세요",
|
||||
))
|
||||
elif content_len > 160:
|
||||
issues.append(self._create_issue(
|
||||
code="S-02",
|
||||
severity="minor",
|
||||
message=f"meta description이 너무 깁니다 ({content_len}자, 권장 50-160자)",
|
||||
suggestion="검색 결과에서 잘리지 않도록 160자 이내로 줄이세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_meta_keywords(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
|
||||
"""S-03: Check meta keywords (informational only)."""
|
||||
keywords = soup.find("meta", attrs={"name": re.compile(r"^keywords$", re.I)})
|
||||
if keywords is None or not keywords.get("content"):
|
||||
meta_info["has_keywords"] = False
|
||||
return [self._create_issue(
|
||||
code="S-03",
|
||||
severity="info",
|
||||
message="meta keywords가 없습니다 (현재 대부분의 검색엔진에서 무시됨)",
|
||||
suggestion="meta keywords는 SEO에 큰 영향이 없지만, 참고용으로 추가할 수 있습니다",
|
||||
)]
|
||||
meta_info["has_keywords"] = True
|
||||
return []
|
||||
|
||||
def _check_og_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-04: Check Open Graph tags (og:title, og:description, og:image)."""
|
||||
issues = []
|
||||
required_og = ["og:title", "og:description", "og:image"]
|
||||
missing = []
|
||||
|
||||
for prop in required_og:
|
||||
og = soup.find("meta", attrs={"property": prop})
|
||||
if og is None or not og.get("content"):
|
||||
missing.append(prop)
|
||||
|
||||
if missing:
|
||||
issues.append(self._create_issue(
|
||||
code="S-04",
|
||||
severity="major",
|
||||
message=f"Open Graph 태그가 누락되었습니다: {', '.join(missing)}",
|
||||
suggestion=f'누락된 OG 태그를 추가하세요. 예: <meta property="{missing[0]}" content="값">',
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_twitter_card(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-05: Check Twitter Card tags."""
|
||||
twitter_card = soup.find("meta", attrs={"name": "twitter:card"})
|
||||
twitter_title = soup.find("meta", attrs={"name": "twitter:title"})
|
||||
|
||||
if twitter_card is None and twitter_title is None:
|
||||
return [self._create_issue(
|
||||
code="S-05",
|
||||
severity="minor",
|
||||
message="Twitter Card 태그가 없습니다",
|
||||
suggestion='<meta name="twitter:card" content="summary_large_image">를 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_canonical(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-06: Check canonical URL."""
|
||||
canonical = soup.find("link", attrs={"rel": "canonical"})
|
||||
if canonical is None or not canonical.get("href"):
|
||||
return [self._create_issue(
|
||||
code="S-06",
|
||||
severity="major",
|
||||
message="canonical URL이 설정되지 않았습니다",
|
||||
suggestion='<link rel="canonical" href="현재페이지URL">을 추가하여 중복 콘텐츠 문제를 방지하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
async def _check_robots_txt(self, url: str, meta_info: dict) -> list[Issue]:
|
||||
"""S-07: Check robots.txt accessibility."""
|
||||
parsed = urlparse(url)
|
||||
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=False) as client:
|
||||
resp = await client.get(robots_url)
|
||||
if resp.status_code == 200:
|
||||
meta_info["has_robots_txt"] = True
|
||||
return []
|
||||
else:
|
||||
meta_info["has_robots_txt"] = False
|
||||
return [self._create_issue(
|
||||
code="S-07",
|
||||
severity="major",
|
||||
message=f"robots.txt에 접근할 수 없습니다 (HTTP {resp.status_code})",
|
||||
suggestion="검색엔진 크롤링을 제어하기 위해 /robots.txt 파일을 생성하세요",
|
||||
)]
|
||||
except Exception as e:
|
||||
logger.warning("robots.txt check failed for %s: %s", url, str(e))
|
||||
meta_info["has_robots_txt"] = False
|
||||
return [self._create_issue(
|
||||
code="S-07",
|
||||
severity="major",
|
||||
message="robots.txt에 접근할 수 없습니다",
|
||||
suggestion="검색엔진 크롤링을 제어하기 위해 /robots.txt 파일을 생성하세요",
|
||||
)]
|
||||
|
||||
async def _check_sitemap(self, url: str, meta_info: dict) -> list[Issue]:
|
||||
"""S-08: Check sitemap.xml accessibility."""
|
||||
parsed = urlparse(url)
|
||||
sitemap_url = f"{parsed.scheme}://{parsed.netloc}/sitemap.xml"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0), verify=False) as client:
|
||||
resp = await client.get(sitemap_url)
|
||||
if resp.status_code == 200:
|
||||
meta_info["has_sitemap"] = True
|
||||
return []
|
||||
else:
|
||||
meta_info["has_sitemap"] = False
|
||||
return [self._create_issue(
|
||||
code="S-08",
|
||||
severity="major",
|
||||
message=f"sitemap.xml에 접근할 수 없습니다 (HTTP {resp.status_code})",
|
||||
suggestion="검색엔진이 사이트 구조를 이해할 수 있도록 /sitemap.xml을 생성하세요",
|
||||
)]
|
||||
except Exception as e:
|
||||
logger.warning("sitemap.xml check failed for %s: %s", url, str(e))
|
||||
meta_info["has_sitemap"] = False
|
||||
return [self._create_issue(
|
||||
code="S-08",
|
||||
severity="major",
|
||||
message="sitemap.xml에 접근할 수 없습니다",
|
||||
suggestion="검색엔진이 사이트 구조를 이해할 수 있도록 /sitemap.xml을 생성하세요",
|
||||
)]
|
||||
|
||||
def _check_h1(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-09: Check H1 tag existence and uniqueness."""
|
||||
h1_tags = soup.find_all("h1")
|
||||
issues = []
|
||||
|
||||
if len(h1_tags) == 0:
|
||||
issues.append(self._create_issue(
|
||||
code="S-09",
|
||||
severity="critical",
|
||||
message="H1 태그가 없습니다",
|
||||
suggestion="페이지의 주요 제목을 <h1> 태그로 추가하세요",
|
||||
))
|
||||
elif len(h1_tags) > 1:
|
||||
issues.append(self._create_issue(
|
||||
code="S-09",
|
||||
severity="critical",
|
||||
message=f"H1 태그가 {len(h1_tags)}개 발견되었습니다 (1개 권장)",
|
||||
element=self._truncate_element(str(h1_tags[0])),
|
||||
suggestion="페이지당 H1 태그는 1개만 사용하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_structured_data(self, soup: BeautifulSoup, html_content: str, meta_info: dict) -> list[Issue]:
|
||||
"""S-10: Check for structured data (JSON-LD, Microdata, RDFa)."""
|
||||
structured_types = []
|
||||
|
||||
# JSON-LD
|
||||
json_ld_scripts = soup.find_all("script", attrs={"type": "application/ld+json"})
|
||||
if json_ld_scripts:
|
||||
structured_types.append("JSON-LD")
|
||||
|
||||
# Microdata
|
||||
microdata = soup.find_all(attrs={"itemscope": True})
|
||||
if microdata:
|
||||
structured_types.append("Microdata")
|
||||
|
||||
# RDFa
|
||||
rdfa = soup.find_all(attrs={"typeof": True})
|
||||
if rdfa:
|
||||
structured_types.append("RDFa")
|
||||
|
||||
meta_info["structured_data_types"] = structured_types
|
||||
|
||||
if not structured_types:
|
||||
return [self._create_issue(
|
||||
code="S-10",
|
||||
severity="minor",
|
||||
message="구조화 데이터(JSON-LD, Microdata, RDFa)가 없습니다",
|
||||
suggestion='<script type="application/ld+json">을 사용하여 구조화 데이터를 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_favicon(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-11: Check favicon existence."""
|
||||
favicon = soup.find("link", attrs={"rel": re.compile(r"icon", re.I)})
|
||||
if favicon is None:
|
||||
return [self._create_issue(
|
||||
code="S-11",
|
||||
severity="minor",
|
||||
message="favicon이 설정되지 않았습니다",
|
||||
suggestion='<link rel="icon" href="/favicon.ico">를 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_viewport(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-12: Check viewport meta tag for mobile friendliness."""
|
||||
viewport = soup.find("meta", attrs={"name": re.compile(r"^viewport$", re.I)})
|
||||
if viewport is None:
|
||||
return [self._create_issue(
|
||||
code="S-12",
|
||||
severity="major",
|
||||
message="viewport meta 태그가 없습니다 (모바일 친화성 부족)",
|
||||
suggestion='<meta name="viewport" content="width=device-width, initial-scale=1.0">을 추가하세요',
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_url_structure(self, url: str) -> list[Issue]:
|
||||
"""S-13: Check URL structure for SEO friendliness."""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
|
||||
# Check for special characters (excluding common ones like /, -, _)
|
||||
special_chars = re.findall(r"[^a-zA-Z0-9/\-_.]", path)
|
||||
if len(special_chars) > 3:
|
||||
return [self._create_issue(
|
||||
code="S-13",
|
||||
severity="minor",
|
||||
message=f"URL에 특수 문자가 많습니다 ({len(special_chars)}개)",
|
||||
suggestion="URL은 영문, 숫자, 하이픈(-)을 사용하여 깔끔하게 구성하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
def _check_img_alt_seo(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""S-14: Check image alt attributes from SEO perspective."""
|
||||
images = soup.find_all("img")
|
||||
if not images:
|
||||
return []
|
||||
|
||||
missing_alt = [img for img in images if not img.get("alt") and img.get("alt") != ""]
|
||||
if missing_alt:
|
||||
return [self._create_issue(
|
||||
code="S-14",
|
||||
severity="major",
|
||||
message=f"alt 속성이 없는 이미지가 {len(missing_alt)}개 발견되었습니다",
|
||||
element=self._truncate_element(str(missing_alt[0])) if missing_alt else None,
|
||||
suggestion="검색엔진이 이미지를 이해할 수 있도록 모든 이미지에 설명적인 alt 속성을 추가하세요",
|
||||
)]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _truncate_element(element_str: str, max_len: int = 200) -> str:
|
||||
if len(element_str) > max_len:
|
||||
return element_str[:max_len] + "..."
|
||||
return element_str
|
||||
@ -1,9 +1,49 @@
|
||||
"""
|
||||
Web Inspector API - FastAPI application entry point.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from datetime import datetime
|
||||
|
||||
app = FastAPI(title="API", version="1.0.0")
|
||||
from app.core.database import connect_db, close_db
|
||||
from app.core.redis import connect_redis, close_redis
|
||||
from app.routers import health, inspections, reports
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: connect/disconnect databases."""
|
||||
# Startup
|
||||
logger.info("Starting Web Inspector API...")
|
||||
await connect_db()
|
||||
await connect_redis()
|
||||
logger.info("Web Inspector API started successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down Web Inspector API...")
|
||||
await close_db()
|
||||
await close_redis()
|
||||
logger.info("Web Inspector API shut down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Web Inspector API",
|
||||
version="1.0.0",
|
||||
description="URL 기반 웹 표준 검사 도구 API",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@ -12,6 +52,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||
# Register routers
|
||||
app.include_router(health.router, prefix="/api", tags=["Health"])
|
||||
app.include_router(inspections.router, prefix="/api", tags=["Inspections"])
|
||||
app.include_router(reports.router, prefix="/api", tags=["Reports"])
|
||||
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
33
backend/app/models/database.py
Normal file
33
backend/app/models/database.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""
|
||||
MongoDB document models and helper functions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def create_inspection_document(
|
||||
inspection_id: str,
|
||||
url: str,
|
||||
status: str,
|
||||
overall_score: int,
|
||||
grade: str,
|
||||
categories: dict,
|
||||
summary: dict,
|
||||
created_at: datetime,
|
||||
completed_at: Optional[datetime] = None,
|
||||
duration_seconds: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""Create a MongoDB document for the inspections collection."""
|
||||
return {
|
||||
"inspection_id": inspection_id,
|
||||
"url": url,
|
||||
"status": status,
|
||||
"created_at": created_at,
|
||||
"completed_at": completed_at,
|
||||
"duration_seconds": duration_seconds,
|
||||
"overall_score": overall_score,
|
||||
"grade": grade,
|
||||
"categories": categories,
|
||||
"summary": summary,
|
||||
}
|
||||
179
backend/app/models/schemas.py
Normal file
179
backend/app/models/schemas.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""
|
||||
Pydantic models for request/response validation and serialization.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# --- Enums ---
|
||||
|
||||
class InspectionStatus(str, Enum):
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
CRITICAL = "critical"
|
||||
MAJOR = "major"
|
||||
MINOR = "minor"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class CategoryName(str, Enum):
|
||||
HTML_CSS = "html_css"
|
||||
ACCESSIBILITY = "accessibility"
|
||||
SEO = "seo"
|
||||
PERFORMANCE_SECURITY = "performance_security"
|
||||
|
||||
|
||||
# --- Request ---
|
||||
|
||||
class StartInspectionRequest(BaseModel):
|
||||
url: HttpUrl
|
||||
|
||||
|
||||
# --- Core Data Models ---
|
||||
|
||||
class Issue(BaseModel):
|
||||
code: str
|
||||
category: str
|
||||
severity: Severity
|
||||
message: str
|
||||
element: Optional[str] = None
|
||||
line: Optional[int] = None
|
||||
suggestion: str
|
||||
wcag_criterion: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryResult(BaseModel):
|
||||
score: int = Field(ge=0, le=100)
|
||||
grade: str
|
||||
total_issues: int
|
||||
critical: int = 0
|
||||
major: int = 0
|
||||
minor: int = 0
|
||||
info: int = 0
|
||||
issues: list[Issue] = []
|
||||
# Category-specific fields
|
||||
wcag_level: Optional[str] = None
|
||||
meta_info: Optional[dict] = None
|
||||
sub_scores: Optional[dict] = None
|
||||
metrics: Optional[dict] = None
|
||||
|
||||
|
||||
class IssueSummary(BaseModel):
|
||||
total_issues: int
|
||||
critical: int
|
||||
major: int
|
||||
minor: int
|
||||
info: int
|
||||
|
||||
|
||||
# --- Response Models ---
|
||||
|
||||
class StartInspectionResponse(BaseModel):
|
||||
inspection_id: str
|
||||
status: str = "running"
|
||||
url: str
|
||||
stream_url: str
|
||||
|
||||
|
||||
class InspectionResult(BaseModel):
|
||||
inspection_id: str
|
||||
url: str
|
||||
status: InspectionStatus
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
overall_score: int = Field(ge=0, le=100)
|
||||
grade: str
|
||||
categories: dict[str, CategoryResult]
|
||||
summary: IssueSummary
|
||||
|
||||
|
||||
class InspectionResultResponse(BaseModel):
|
||||
"""Response model for GET /api/inspections/{id} (without nested issues)."""
|
||||
inspection_id: str
|
||||
url: str
|
||||
status: InspectionStatus
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
overall_score: int = Field(ge=0, le=100)
|
||||
grade: str
|
||||
categories: dict[str, CategoryResult]
|
||||
summary: IssueSummary
|
||||
|
||||
|
||||
class IssueListResponse(BaseModel):
|
||||
inspection_id: str
|
||||
total: int
|
||||
filters: dict
|
||||
issues: list[Issue]
|
||||
|
||||
|
||||
class InspectionListItem(BaseModel):
|
||||
inspection_id: str
|
||||
url: str
|
||||
created_at: datetime
|
||||
overall_score: int
|
||||
grade: str
|
||||
total_issues: int
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
items: list[InspectionListItem]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class TrendDataPoint(BaseModel):
|
||||
inspection_id: str
|
||||
created_at: datetime
|
||||
overall_score: int
|
||||
html_css: int
|
||||
accessibility: int
|
||||
seo: int
|
||||
performance_security: int
|
||||
|
||||
|
||||
class TrendResponse(BaseModel):
|
||||
url: str
|
||||
data_points: list[TrendDataPoint]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
timestamp: str
|
||||
services: dict[str, str]
|
||||
|
||||
|
||||
# --- Utility functions ---
|
||||
|
||||
def calculate_grade(score: int) -> str:
|
||||
"""Calculate letter grade from numeric score."""
|
||||
if score >= 90:
|
||||
return "A+"
|
||||
if score >= 80:
|
||||
return "A"
|
||||
if score >= 70:
|
||||
return "B"
|
||||
if score >= 60:
|
||||
return "C"
|
||||
if score >= 50:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
|
||||
def calculate_overall_score(categories: dict[str, CategoryResult]) -> int:
|
||||
"""Calculate overall score as simple average of category scores."""
|
||||
scores = [cat.score for cat in categories.values()]
|
||||
if not scores:
|
||||
return 0
|
||||
return round(sum(scores) / len(scores))
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
50
backend/app/routers/health.py
Normal file
50
backend/app/routers/health.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""
|
||||
Health check router.
|
||||
GET /api/health
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.redis import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Check system health including MongoDB and Redis connectivity."""
|
||||
services = {}
|
||||
|
||||
# Check MongoDB
|
||||
try:
|
||||
db = get_db()
|
||||
await db.command("ping")
|
||||
services["mongodb"] = "connected"
|
||||
except Exception as e:
|
||||
logger.error("MongoDB health check failed: %s", str(e))
|
||||
services["mongodb"] = "disconnected"
|
||||
|
||||
# Check Redis
|
||||
try:
|
||||
redis = get_redis()
|
||||
await redis.ping()
|
||||
services["redis"] = "connected"
|
||||
except Exception as e:
|
||||
logger.error("Redis health check failed: %s", str(e))
|
||||
services["redis"] = "disconnected"
|
||||
|
||||
# Overall status
|
||||
all_connected = all(v == "connected" for v in services.values())
|
||||
status = "healthy" if all_connected else "degraded"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"services": services,
|
||||
}
|
||||
252
backend/app/routers/inspections.py
Normal file
252
backend/app/routers/inspections.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""
|
||||
Inspections router.
|
||||
Handles inspection lifecycle: start, SSE stream, result, issues, history, trend.
|
||||
|
||||
IMPORTANT: Static paths (/batch, /trend) must be registered BEFORE
|
||||
dynamic paths (/{id}) to avoid routing conflicts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import HttpUrl, ValidationError
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.redis import get_redis, get_current_progress
|
||||
from app.models.schemas import StartInspectionRequest, StartInspectionResponse
|
||||
from app.services.inspection_service import InspectionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> InspectionService:
|
||||
"""Get InspectionService instance."""
|
||||
db = get_db()
|
||||
redis = get_redis()
|
||||
return InspectionService(db=db, redis=redis)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# POST /api/inspections -- Start inspection
|
||||
# ============================================================
|
||||
|
||||
@router.post("/inspections", status_code=202)
|
||||
async def start_inspection(request: StartInspectionRequest):
|
||||
"""
|
||||
Start a new web inspection.
|
||||
Returns 202 Accepted with inspection_id immediately.
|
||||
Inspection runs asynchronously in the background.
|
||||
"""
|
||||
url = str(request.url)
|
||||
|
||||
# Validate URL scheme
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="유효한 URL을 입력해주세요 (http:// 또는 https://로 시작해야 합니다)",
|
||||
)
|
||||
|
||||
service = _get_service()
|
||||
|
||||
try:
|
||||
inspection_id = await service.start_inspection(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"해당 URL에 접근할 수 없습니다 (HTTP {e.response.status_code})",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다 (응답 시간 초과)",
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start inspection: %s", str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="해당 URL에 접근할 수 없습니다",
|
||||
)
|
||||
|
||||
return StartInspectionResponse(
|
||||
inspection_id=inspection_id,
|
||||
status="running",
|
||||
url=url,
|
||||
stream_url=f"/api/inspections/{inspection_id}/stream",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections -- List inspections (history)
|
||||
# IMPORTANT: This MUST be before /{inspection_id} routes
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections")
|
||||
async def list_inspections(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
url: Optional[str] = Query(default=None),
|
||||
sort: str = Query(default="-created_at"),
|
||||
):
|
||||
"""Get paginated inspection history."""
|
||||
service = _get_service()
|
||||
result = await service.get_inspection_list(
|
||||
page=page,
|
||||
limit=limit,
|
||||
url_filter=url,
|
||||
sort=sort,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/trend -- Trend data
|
||||
# IMPORTANT: Must be before /{inspection_id} to avoid conflict
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/trend")
|
||||
async def get_trend(
|
||||
url: str = Query(..., description="Target URL for trend data"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
):
|
||||
"""Get trend data (score history) for a specific URL."""
|
||||
service = _get_service()
|
||||
result = await service.get_trend(url=url, limit=limit)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id}/stream -- SSE stream
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}/stream")
|
||||
async def stream_progress(inspection_id: str):
|
||||
"""Stream inspection progress via Server-Sent Events."""
|
||||
|
||||
async def event_generator():
|
||||
redis = get_redis()
|
||||
pubsub = redis.pubsub()
|
||||
channel = f"inspection:{inspection_id}:events"
|
||||
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
try:
|
||||
# Send current state immediately (client may connect mid-progress)
|
||||
current = await get_current_progress(inspection_id)
|
||||
if current:
|
||||
# Build initial progress event
|
||||
categories = {}
|
||||
for cat in ["html_css", "accessibility", "seo", "performance_security"]:
|
||||
cat_progress = int(current.get(f"{cat}_progress", 0))
|
||||
cat_step = current.get(f"{cat}_step", "")
|
||||
cat_status = current.get(f"{cat}_status", "pending")
|
||||
categories[cat] = {
|
||||
"status": cat_status,
|
||||
"progress": cat_progress,
|
||||
"current_step": cat_step,
|
||||
}
|
||||
|
||||
total = sum(c["progress"] for c in categories.values())
|
||||
overall = round(total / 4)
|
||||
|
||||
yield {
|
||||
"event": "progress",
|
||||
"data": json.dumps({
|
||||
"inspection_id": inspection_id,
|
||||
"status": "running",
|
||||
"overall_progress": overall,
|
||||
"categories": categories,
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# Listen for Pub/Sub messages
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
event_data = json.loads(message["data"])
|
||||
event_type = event_data.pop("event_type", "progress")
|
||||
|
||||
yield {
|
||||
"event": event_type,
|
||||
"data": json.dumps(event_data, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# End stream on complete or error
|
||||
if event_type in ("complete", "error"):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SSE stream error for %s: %s", inspection_id, str(e))
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": json.dumps({
|
||||
"inspection_id": inspection_id,
|
||||
"status": "error",
|
||||
"message": "스트리밍 중 오류가 발생했습니다",
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.aclose()
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id} -- Get result
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}")
|
||||
async def get_inspection(inspection_id: str):
|
||||
"""Get inspection result by ID."""
|
||||
service = _get_service()
|
||||
result = await service.get_inspection(inspection_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
# Remove MongoDB _id field if present
|
||||
result.pop("_id", None)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /api/inspections/{inspection_id}/issues -- Get issues
|
||||
# ============================================================
|
||||
|
||||
@router.get("/inspections/{inspection_id}/issues")
|
||||
async def get_issues(
|
||||
inspection_id: str,
|
||||
category: Optional[str] = Query(default=None),
|
||||
severity: Optional[str] = Query(default=None),
|
||||
):
|
||||
"""Get filtered issue list for an inspection."""
|
||||
service = _get_service()
|
||||
result = await service.get_issues(
|
||||
inspection_id=inspection_id,
|
||||
category=category,
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
return result
|
||||
81
backend/app/routers/reports.py
Normal file
81
backend/app/routers/reports.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""
|
||||
Reports router.
|
||||
Handles PDF and JSON report download.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.redis import get_redis
|
||||
from app.services.inspection_service import InspectionService
|
||||
from app.services.report_service import ReportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
report_service = ReportService()
|
||||
|
||||
|
||||
def _get_inspection_service() -> InspectionService:
|
||||
"""Get InspectionService instance."""
|
||||
db = get_db()
|
||||
redis = get_redis()
|
||||
return InspectionService(db=db, redis=redis)
|
||||
|
||||
|
||||
@router.get("/inspections/{inspection_id}/report/pdf")
|
||||
async def download_pdf(inspection_id: str):
|
||||
"""Download inspection report as PDF."""
|
||||
service = _get_inspection_service()
|
||||
inspection = await service.get_inspection(inspection_id)
|
||||
|
||||
if inspection is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
try:
|
||||
pdf_bytes = await report_service.generate_pdf(inspection)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
filename = report_service.generate_filename(inspection.get("url", "unknown"), "pdf")
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inspections/{inspection_id}/report/json")
|
||||
async def download_json(inspection_id: str):
|
||||
"""Download inspection report as JSON file."""
|
||||
service = _get_inspection_service()
|
||||
inspection = await service.get_inspection(inspection_id)
|
||||
|
||||
if inspection is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="검사 결과를 찾을 수 없습니다",
|
||||
)
|
||||
|
||||
json_bytes = await report_service.generate_json(inspection)
|
||||
filename = report_service.generate_filename(inspection.get("url", "unknown"), "json")
|
||||
|
||||
return Response(
|
||||
content=json_bytes,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
493
backend/app/services/inspection_service.py
Normal file
493
backend/app/services/inspection_service.py
Normal file
@ -0,0 +1,493 @@
|
||||
"""
|
||||
Inspection orchestration service.
|
||||
Manages the full inspection lifecycle:
|
||||
- URL validation and fetching
|
||||
- Parallel execution of 4 checker engines
|
||||
- Progress tracking via Redis
|
||||
- Result aggregation and storage in MongoDB
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.redis import (
|
||||
set_inspection_status,
|
||||
update_category_progress,
|
||||
publish_event,
|
||||
cache_result,
|
||||
)
|
||||
from app.engines.html_css import HtmlCssChecker
|
||||
from app.engines.accessibility import AccessibilityChecker
|
||||
from app.engines.seo import SeoChecker
|
||||
from app.engines.performance_security import PerformanceSecurityChecker
|
||||
from app.models.schemas import (
|
||||
CategoryResult,
|
||||
InspectionResult,
|
||||
IssueSummary,
|
||||
Severity,
|
||||
calculate_grade,
|
||||
calculate_overall_score,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InspectionService:
|
||||
"""Inspection orchestration service."""
|
||||
|
||||
def __init__(self, db: AsyncIOMotorDatabase, redis: Redis):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
|
||||
async def start_inspection(self, url: str) -> str:
|
||||
"""
|
||||
Start an inspection and return the inspection_id.
|
||||
1. Validate URL accessibility (timeout 10s)
|
||||
2. Generate inspection_id (UUID v4)
|
||||
3. Initialize progress state in Redis
|
||||
4. Launch background inspection task
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# 1. Fetch URL to verify accessibility
|
||||
response = await self._fetch_url(url, timeout=settings.URL_FETCH_TIMEOUT)
|
||||
|
||||
# 2. Generate inspection_id
|
||||
inspection_id = str(uuid.uuid4())
|
||||
|
||||
# 3. Initialize Redis state
|
||||
await self._init_progress(inspection_id, url)
|
||||
|
||||
# 4. Run inspection as background task
|
||||
asyncio.create_task(
|
||||
self._run_inspection(inspection_id, url, response)
|
||||
)
|
||||
|
||||
return inspection_id
|
||||
|
||||
async def _run_inspection(
|
||||
self, inspection_id: str, url: str, response: httpx.Response
|
||||
) -> None:
|
||||
"""Execute 4 category checks in parallel and store results."""
|
||||
html_content = response.text
|
||||
headers = dict(response.headers)
|
||||
start_time = time.time()
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
# Progress callback factory
|
||||
async def progress_callback(category: str, progress: int, current_step: str):
|
||||
await self._update_progress(inspection_id, category, progress, current_step)
|
||||
|
||||
# Create 4 checker engines
|
||||
checkers = [
|
||||
HtmlCssChecker(progress_callback=progress_callback),
|
||||
AccessibilityChecker(progress_callback=progress_callback),
|
||||
SeoChecker(progress_callback=progress_callback),
|
||||
PerformanceSecurityChecker(progress_callback=progress_callback),
|
||||
]
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Parallel execution with per-category timeout
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
asyncio.wait_for(
|
||||
checker.check(url, html_content, headers),
|
||||
timeout=settings.CATEGORY_TIMEOUT,
|
||||
)
|
||||
for checker in checkers
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Process results (handle timeouts/errors per category)
|
||||
categories = {}
|
||||
category_names = ["html_css", "accessibility", "seo", "performance_security"]
|
||||
|
||||
for i, result in enumerate(results):
|
||||
cat_name = category_names[i]
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Category %s failed for inspection %s: %s",
|
||||
cat_name, inspection_id, str(result),
|
||||
)
|
||||
# Create error result for failed category
|
||||
categories[cat_name] = CategoryResult(
|
||||
score=0,
|
||||
grade="F",
|
||||
total_issues=0,
|
||||
issues=[],
|
||||
)
|
||||
# Publish category error
|
||||
await publish_event(inspection_id, {
|
||||
"event_type": "category_complete",
|
||||
"inspection_id": inspection_id,
|
||||
"category": cat_name,
|
||||
"score": 0,
|
||||
"total_issues": 0,
|
||||
})
|
||||
else:
|
||||
categories[cat_name] = result
|
||||
# Publish category completion
|
||||
await publish_event(inspection_id, {
|
||||
"event_type": "category_complete",
|
||||
"inspection_id": inspection_id,
|
||||
"category": cat_name,
|
||||
"score": result.score,
|
||||
"total_issues": result.total_issues,
|
||||
})
|
||||
|
||||
# Calculate overall score
|
||||
overall_score = calculate_overall_score(categories)
|
||||
grade = calculate_grade(overall_score)
|
||||
duration = round(time.time() - start_time, 1)
|
||||
|
||||
# Build summary
|
||||
total_critical = sum(c.critical for c in categories.values())
|
||||
total_major = sum(c.major for c in categories.values())
|
||||
total_minor = sum(c.minor for c in categories.values())
|
||||
total_info = sum(c.info for c in categories.values())
|
||||
total_issues = sum(c.total_issues for c in categories.values())
|
||||
|
||||
summary = IssueSummary(
|
||||
total_issues=total_issues,
|
||||
critical=total_critical,
|
||||
major=total_major,
|
||||
minor=total_minor,
|
||||
info=total_info,
|
||||
)
|
||||
|
||||
# Build inspection result
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
inspection_result = InspectionResult(
|
||||
inspection_id=inspection_id,
|
||||
url=url,
|
||||
status="completed",
|
||||
created_at=created_at,
|
||||
completed_at=completed_at,
|
||||
duration_seconds=duration,
|
||||
overall_score=overall_score,
|
||||
grade=grade,
|
||||
categories=categories,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
# Store in MongoDB
|
||||
doc = inspection_result.model_dump(mode="json")
|
||||
await self.db.inspections.insert_one(doc)
|
||||
|
||||
# Enforce URL history limit (max 100 per URL)
|
||||
await self._enforce_history_limit(url, max_count=100)
|
||||
|
||||
# Cache in Redis
|
||||
await cache_result(inspection_id, doc)
|
||||
|
||||
# Mark as completed
|
||||
await set_inspection_status(inspection_id, "completed")
|
||||
|
||||
# Publish complete event
|
||||
await publish_event(inspection_id, {
|
||||
"event_type": "complete",
|
||||
"inspection_id": inspection_id,
|
||||
"status": "completed",
|
||||
"overall_score": overall_score,
|
||||
"redirect_url": f"/inspections/{inspection_id}",
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Inspection %s completed: score=%d, duration=%.1fs",
|
||||
inspection_id, overall_score, duration,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Inspection %s failed: %s", inspection_id, str(e), exc_info=True
|
||||
)
|
||||
await set_inspection_status(inspection_id, "error")
|
||||
await publish_event(inspection_id, {
|
||||
"event_type": "error",
|
||||
"inspection_id": inspection_id,
|
||||
"status": "error",
|
||||
"message": "검사 중 오류가 발생했습니다",
|
||||
})
|
||||
|
||||
# Store error record in MongoDB
|
||||
error_doc = {
|
||||
"inspection_id": inspection_id,
|
||||
"url": url,
|
||||
"status": "error",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"error_message": str(e)[:500],
|
||||
"overall_score": 0,
|
||||
"grade": "F",
|
||||
"categories": {},
|
||||
"summary": {
|
||||
"total_issues": 0,
|
||||
"critical": 0,
|
||||
"major": 0,
|
||||
"minor": 0,
|
||||
"info": 0,
|
||||
},
|
||||
}
|
||||
await self.db.inspections.insert_one(error_doc)
|
||||
|
||||
async def _fetch_url(self, url: str, timeout: int = 10) -> httpx.Response:
|
||||
"""Fetch URL content with timeout."""
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(float(timeout)),
|
||||
verify=False,
|
||||
) as client:
|
||||
response = await client.get(url, headers={
|
||||
"User-Agent": "WebInspector/1.0 (Inspection Bot)",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
})
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
async def _init_progress(self, inspection_id: str, url: str) -> None:
|
||||
"""Initialize inspection progress in Redis."""
|
||||
await set_inspection_status(inspection_id, "running")
|
||||
|
||||
# Initialize all category progresses
|
||||
for cat in ["html_css", "accessibility", "seo", "performance_security"]:
|
||||
await update_category_progress(inspection_id, cat, 0, "대기 중...")
|
||||
|
||||
async def _update_progress(
|
||||
self, inspection_id: str, category: str, progress: int, current_step: str
|
||||
) -> None:
|
||||
"""Update category progress and publish SSE event."""
|
||||
await update_category_progress(inspection_id, category, progress, current_step)
|
||||
|
||||
# Build full progress state
|
||||
progress_data = await self._build_progress_event(inspection_id, category, progress, current_step)
|
||||
|
||||
await publish_event(inspection_id, progress_data)
|
||||
|
||||
async def _build_progress_event(
|
||||
self, inspection_id: str, updated_category: str, progress: int, current_step: str
|
||||
) -> dict:
|
||||
"""Build progress event data including all categories."""
|
||||
from app.core.redis import get_current_progress
|
||||
|
||||
raw = await get_current_progress(inspection_id)
|
||||
|
||||
categories = {}
|
||||
category_list = ["html_css", "accessibility", "seo", "performance_security"]
|
||||
|
||||
for cat in category_list:
|
||||
if raw:
|
||||
cat_progress = int(raw.get(f"{cat}_progress", 0))
|
||||
cat_step = raw.get(f"{cat}_step", "")
|
||||
cat_status = raw.get(f"{cat}_status", "pending")
|
||||
else:
|
||||
cat_progress = 0
|
||||
cat_step = ""
|
||||
cat_status = "pending"
|
||||
|
||||
# Override with just-updated values
|
||||
if cat == updated_category:
|
||||
cat_progress = progress
|
||||
cat_step = current_step
|
||||
cat_status = "completed" if progress >= 100 else "running"
|
||||
|
||||
categories[cat] = {
|
||||
"status": cat_status,
|
||||
"progress": cat_progress,
|
||||
"current_step": cat_step,
|
||||
}
|
||||
|
||||
# Calculate overall progress
|
||||
total_progress = sum(c["progress"] for c in categories.values())
|
||||
overall_progress = round(total_progress / len(categories))
|
||||
|
||||
return {
|
||||
"event_type": "progress",
|
||||
"inspection_id": inspection_id,
|
||||
"status": "running",
|
||||
"overall_progress": overall_progress,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
async def _enforce_history_limit(self, url: str, max_count: int = 100) -> None:
|
||||
"""Delete oldest inspection records if URL exceeds max_count."""
|
||||
count = await self.db.inspections.count_documents({"url": url})
|
||||
if count > max_count:
|
||||
excess = count - max_count
|
||||
oldest = self.db.inspections.find(
|
||||
{"url": url}
|
||||
).sort("created_at", 1).limit(excess)
|
||||
|
||||
ids_to_delete = []
|
||||
async for doc in oldest:
|
||||
ids_to_delete.append(doc["_id"])
|
||||
|
||||
if ids_to_delete:
|
||||
await self.db.inspections.delete_many({"_id": {"$in": ids_to_delete}})
|
||||
logger.info(
|
||||
"Deleted %d oldest inspections for URL %s",
|
||||
len(ids_to_delete), url,
|
||||
)
|
||||
|
||||
async def get_inspection(self, inspection_id: str) -> Optional[dict]:
|
||||
"""Get inspection result by ID (cache-first)."""
|
||||
from app.core.redis import get_cached_result, cache_result
|
||||
|
||||
# Try cache first
|
||||
cached = await get_cached_result(inspection_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Fetch from MongoDB
|
||||
doc = await self.db.inspections.find_one(
|
||||
{"inspection_id": inspection_id},
|
||||
{"_id": 0},
|
||||
)
|
||||
if doc:
|
||||
await cache_result(inspection_id, doc)
|
||||
return doc
|
||||
return None
|
||||
|
||||
async def get_issues(
|
||||
self,
|
||||
inspection_id: str,
|
||||
category: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Get filtered issues for an inspection."""
|
||||
doc = await self.get_inspection(inspection_id)
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
all_issues = []
|
||||
categories = doc.get("categories", {})
|
||||
|
||||
for cat_name, cat_data in categories.items():
|
||||
if category and category != "all" and cat_name != category:
|
||||
continue
|
||||
for issue in cat_data.get("issues", []):
|
||||
if severity and severity != "all" and issue.get("severity") != severity:
|
||||
continue
|
||||
all_issues.append(issue)
|
||||
|
||||
# Sort by severity priority
|
||||
severity_order = {"critical": 0, "major": 1, "minor": 2, "info": 3}
|
||||
all_issues.sort(key=lambda x: severity_order.get(x.get("severity", "info"), 4))
|
||||
|
||||
return {
|
||||
"inspection_id": inspection_id,
|
||||
"total": len(all_issues),
|
||||
"filters": {
|
||||
"category": category or "all",
|
||||
"severity": severity or "all",
|
||||
},
|
||||
"issues": all_issues,
|
||||
}
|
||||
|
||||
async def get_inspection_list(
|
||||
self,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
url_filter: Optional[str] = None,
|
||||
sort: str = "-created_at",
|
||||
) -> dict:
|
||||
"""Get paginated inspection list."""
|
||||
limit = min(limit, 100)
|
||||
skip = (page - 1) * limit
|
||||
|
||||
# Build query
|
||||
query = {}
|
||||
if url_filter:
|
||||
query["url"] = {"$regex": url_filter, "$options": "i"}
|
||||
|
||||
# Sort direction
|
||||
if sort.startswith("-"):
|
||||
sort_field = sort[1:]
|
||||
sort_dir = -1
|
||||
else:
|
||||
sort_field = sort
|
||||
sort_dir = 1
|
||||
|
||||
# Count total
|
||||
total = await self.db.inspections.count_documents(query)
|
||||
|
||||
# Fetch items
|
||||
cursor = self.db.inspections.find(
|
||||
query,
|
||||
{
|
||||
"_id": 0,
|
||||
"inspection_id": 1,
|
||||
"url": 1,
|
||||
"created_at": 1,
|
||||
"overall_score": 1,
|
||||
"grade": 1,
|
||||
"summary.total_issues": 1,
|
||||
},
|
||||
).sort(sort_field, sort_dir).skip(skip).limit(limit)
|
||||
|
||||
items = []
|
||||
async for doc in cursor:
|
||||
items.append({
|
||||
"inspection_id": doc.get("inspection_id"),
|
||||
"url": doc.get("url"),
|
||||
"created_at": doc.get("created_at"),
|
||||
"overall_score": doc.get("overall_score", 0),
|
||||
"grade": doc.get("grade", "F"),
|
||||
"total_issues": doc.get("summary", {}).get("total_issues", 0),
|
||||
})
|
||||
|
||||
total_pages = max(1, -(-total // limit)) # Ceiling division
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
async def get_trend(self, url: str, limit: int = 10) -> dict:
|
||||
"""Get trend data for a specific URL."""
|
||||
cursor = self.db.inspections.find(
|
||||
{"url": url, "status": "completed"},
|
||||
{
|
||||
"_id": 0,
|
||||
"inspection_id": 1,
|
||||
"created_at": 1,
|
||||
"overall_score": 1,
|
||||
"categories.html_css.score": 1,
|
||||
"categories.accessibility.score": 1,
|
||||
"categories.seo.score": 1,
|
||||
"categories.performance_security.score": 1,
|
||||
},
|
||||
).sort("created_at", 1).limit(limit)
|
||||
|
||||
data_points = []
|
||||
async for doc in cursor:
|
||||
cats = doc.get("categories", {})
|
||||
data_points.append({
|
||||
"inspection_id": doc.get("inspection_id"),
|
||||
"created_at": doc.get("created_at"),
|
||||
"overall_score": doc.get("overall_score", 0),
|
||||
"html_css": cats.get("html_css", {}).get("score", 0),
|
||||
"accessibility": cats.get("accessibility", {}).get("score", 0),
|
||||
"seo": cats.get("seo", {}).get("score", 0),
|
||||
"performance_security": cats.get("performance_security", {}).get("score", 0),
|
||||
})
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"data_points": data_points,
|
||||
}
|
||||
95
backend/app/services/report_service.py
Normal file
95
backend/app/services/report_service.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""
|
||||
Report generation service.
|
||||
Generates PDF and JSON reports from inspection results.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from slugify import slugify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
|
||||
# Grade color mapping
|
||||
GRADE_COLORS = {
|
||||
"A+": "#22C55E",
|
||||
"A": "#22C55E",
|
||||
"B": "#3B82F6",
|
||||
"C": "#F59E0B",
|
||||
"D": "#F97316",
|
||||
"F": "#EF4444",
|
||||
}
|
||||
|
||||
SEVERITY_COLORS = {
|
||||
"critical": "#EF4444",
|
||||
"major": "#F97316",
|
||||
"minor": "#EAB308",
|
||||
"info": "#3B82F6",
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"html_css": "HTML/CSS 표준",
|
||||
"accessibility": "접근성 (WCAG)",
|
||||
"seo": "SEO 최적화",
|
||||
"performance_security": "성능/보안",
|
||||
}
|
||||
|
||||
|
||||
class ReportService:
|
||||
"""PDF and JSON report generation service."""
|
||||
|
||||
def __init__(self):
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=True,
|
||||
)
|
||||
# Register custom filters
|
||||
self.env.filters["grade_color"] = lambda g: GRADE_COLORS.get(g, "#6B7280")
|
||||
self.env.filters["severity_color"] = lambda s: SEVERITY_COLORS.get(s, "#6B7280")
|
||||
self.env.filters["category_label"] = lambda c: CATEGORY_LABELS.get(c, c)
|
||||
|
||||
async def generate_pdf(self, inspection: dict) -> bytes:
|
||||
"""Generate PDF report from inspection result."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
template = self.env.get_template("report.html")
|
||||
html_string = template.render(
|
||||
inspection=inspection,
|
||||
generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
grade_colors=GRADE_COLORS,
|
||||
severity_colors=SEVERITY_COLORS,
|
||||
category_labels=CATEGORY_LABELS,
|
||||
)
|
||||
pdf_bytes = HTML(string=html_string).write_pdf()
|
||||
return pdf_bytes
|
||||
|
||||
except ImportError:
|
||||
logger.error("WeasyPrint is not installed")
|
||||
raise RuntimeError("PDF generation is not available (WeasyPrint not installed)")
|
||||
except Exception as e:
|
||||
logger.error("PDF generation failed: %s", str(e))
|
||||
raise RuntimeError(f"PDF generation failed: {str(e)}")
|
||||
|
||||
async def generate_json(self, inspection: dict) -> bytes:
|
||||
"""Generate JSON report from inspection result."""
|
||||
# Remove MongoDB internal fields
|
||||
clean_data = {k: v for k, v in inspection.items() if k != "_id"}
|
||||
json_str = json.dumps(clean_data, ensure_ascii=False, indent=2, default=str)
|
||||
return json_str.encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def generate_filename(url: str, extension: str) -> str:
|
||||
"""Generate download filename: web-inspector-{url-slug}-{date}.{ext}"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname or "unknown"
|
||||
url_slug = slugify(hostname, max_length=50)
|
||||
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
return f"web-inspector-{url_slug}-{date_str}.{extension}"
|
||||
2
backend/app/templates/report.css
Normal file
2
backend/app/templates/report.css
Normal file
@ -0,0 +1,2 @@
|
||||
/* Additional styles for PDF report - can be extended */
|
||||
/* Main styles are inline in report.html for WeasyPrint compatibility */
|
||||
307
backend/app/templates/report.html
Normal file
307
backend/app/templates/report.html
Normal file
@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Web Inspector Report - {{ inspection.url }}</title>
|
||||
<link rel="stylesheet" href="report.css">
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'Noto Sans KR', 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
color: #1F2937;
|
||||
line-height: 1.6;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
text-align: center;
|
||||
padding: 60px 0 40px;
|
||||
border-bottom: 3px solid #6366F1;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 24pt;
|
||||
color: #6366F1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 12pt;
|
||||
color: #6B7280;
|
||||
}
|
||||
.cover .url {
|
||||
font-size: 11pt;
|
||||
color: #374151;
|
||||
margin-top: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cover .date {
|
||||
font-size: 9pt;
|
||||
color: #9CA3AF;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Score Section */
|
||||
.overall-score {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.score-circle {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
line-height: 120px;
|
||||
text-align: center;
|
||||
font-size: 36pt;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.score-grade {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Category Table */
|
||||
.category-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.category-table th, .category-table td {
|
||||
border: 1px solid #E5E7EB;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.category-table th {
|
||||
background: #F9FAFB;
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
}
|
||||
.category-table td {
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
/* Issues Section */
|
||||
.issues-section {
|
||||
margin-bottom: 30px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.issues-section h2 {
|
||||
font-size: 14pt;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #E5E7EB;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.issue-card {
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.issue-code {
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
color: #6B7280;
|
||||
}
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.severity-critical { background: #EF4444; }
|
||||
.severity-major { background: #F97316; }
|
||||
.severity-minor { background: #EAB308; color: #1F2937; }
|
||||
.severity-info { background: #3B82F6; }
|
||||
.issue-message {
|
||||
font-size: 9pt;
|
||||
color: #374151;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.issue-suggestion {
|
||||
font-size: 8pt;
|
||||
color: #6366F1;
|
||||
background: #EEF2FF;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.issue-element {
|
||||
font-size: 7pt;
|
||||
color: #6B7280;
|
||||
background: #F9FAFB;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.summary-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.summary-count {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 8pt;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #9CA3AF;
|
||||
border-top: 1px solid #E5E7EB;
|
||||
padding-top: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 12pt;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Cover Page -->
|
||||
<div class="cover">
|
||||
<h1>Web Inspector</h1>
|
||||
<div class="subtitle">웹 표준 검사 리포트</div>
|
||||
<div class="url">{{ inspection.url }}</div>
|
||||
<div class="date">
|
||||
검사일시: {{ inspection.created_at }}
|
||||
{% if inspection.duration_seconds %} | 소요시간: {{ inspection.duration_seconds }}초{% endif %}
|
||||
</div>
|
||||
<div class="date">리포트 생성: {{ generated_at }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Score -->
|
||||
<div class="overall-score">
|
||||
<div class="score-circle" style="background: {{ inspection.grade | grade_color }};">
|
||||
{{ inspection.overall_score }}
|
||||
</div>
|
||||
<div class="score-grade" style="color: {{ inspection.grade | grade_color }};">
|
||||
등급: {{ inspection.grade }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Summary Table -->
|
||||
<table class="category-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>카테고리</th>
|
||||
<th>점수</th>
|
||||
<th>등급</th>
|
||||
<th>이슈 수</th>
|
||||
<th>Critical</th>
|
||||
<th>Major</th>
|
||||
<th>Minor</th>
|
||||
<th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cat_name, cat_data in inspection.categories.items() %}
|
||||
<tr>
|
||||
<td style="text-align: left; font-weight: 600;">{{ cat_name | category_label }}</td>
|
||||
<td style="font-weight: bold; color: {{ cat_data.grade | grade_color }};">{{ cat_data.score }}</td>
|
||||
<td>{{ cat_data.grade }}</td>
|
||||
<td>{{ cat_data.total_issues }}</td>
|
||||
<td style="color: #EF4444;">{{ cat_data.critical }}</td>
|
||||
<td style="color: #F97316;">{{ cat_data.major }}</td>
|
||||
<td style="color: #EAB308;">{{ cat_data.minor }}</td>
|
||||
<td style="color: #3B82F6;">{{ cat_data.info }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Issue Summary -->
|
||||
<div class="summary-section">
|
||||
<h3>이슈 요약</h3>
|
||||
<div class="summary-bar">
|
||||
<div class="summary-item" style="background: #FEE2E2;">
|
||||
<div class="summary-count" style="color: #EF4444;">{{ inspection.summary.critical }}</div>
|
||||
<div class="summary-label">Critical</div>
|
||||
</div>
|
||||
<div class="summary-item" style="background: #FFEDD5;">
|
||||
<div class="summary-count" style="color: #F97316;">{{ inspection.summary.major }}</div>
|
||||
<div class="summary-label">Major</div>
|
||||
</div>
|
||||
<div class="summary-item" style="background: #FEF9C3;">
|
||||
<div class="summary-count" style="color: #EAB308;">{{ inspection.summary.minor }}</div>
|
||||
<div class="summary-label">Minor</div>
|
||||
</div>
|
||||
<div class="summary-item" style="background: #DBEAFE;">
|
||||
<div class="summary-count" style="color: #3B82F6;">{{ inspection.summary.info }}</div>
|
||||
<div class="summary-label">Info</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issues by Category -->
|
||||
{% for cat_name, cat_data in inspection.categories.items() %}
|
||||
{% if cat_data.issues %}
|
||||
<div class="issues-section">
|
||||
<h2>{{ cat_name | category_label }} ({{ cat_data.score }}점)</h2>
|
||||
{% for issue in cat_data.issues %}
|
||||
<div class="issue-card">
|
||||
<div class="issue-header">
|
||||
<span class="issue-code">{{ issue.code }}</span>
|
||||
<span class="severity-badge severity-{{ issue.severity }}">{{ issue.severity | upper }}</span>
|
||||
</div>
|
||||
<div class="issue-message">{{ issue.message }}</div>
|
||||
{% if issue.element %}
|
||||
<div class="issue-element">{{ issue.element }}</div>
|
||||
{% endif %}
|
||||
{% if issue.suggestion %}
|
||||
<div class="issue-suggestion">{{ issue.suggestion }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
Web Inspector Report | Generated by Web Inspector v1.0
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user