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

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

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

View File

View 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()

View 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
View 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

View File

View 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

File diff suppressed because one or more lines are too long

108
backend/app/engines/base.py Normal file
View 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,
)

View 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

View 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
View 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

View File

@ -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"])

View File

View 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,
}

View 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))

View File

View 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,
}

View 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

View 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}"',
},
)

View File

View 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,
}

View 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}"

View File

@ -0,0 +1,2 @@
/* Additional styles for PDF report - can be extended */
/* Main styles are inline in report.html for WeasyPrint compatibility */

View 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>