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:
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
|
||||
Reference in New Issue
Block a user