refactor: 4개 검사 엔진을 YAML 기반 표준 규칙으로 리팩토링

- YAML 규칙 파일 4개 신규 생성 (html_css, accessibility, seo, performance_security)
  W3C, WCAG 2.0/2.1/2.2, OWASP, Google Search Essentials 공식 표준 기반
- rules/__init__.py: YAML 로더 + 캐싱 + 리로드 모듈
- html_css.py: 30개 폐기 요소, 100+개 폐기 속성을 YAML에서 동적 로드
- accessibility.py: WCAG 버전 선택 지원 (wcag_version 파라미터)
- seo.py: title/description 길이, OG 필수 태그 등 임계값 YAML 로드
- performance_security.py: COOP/COEP/CORP 검사 추가, 정보 노출 헤더 검사 추가,
  TTFB/페이지 크기 임계값 YAML 로드
- PyYAML 의존성 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 15:49:57 +09:00
parent cdb6405714
commit 44ad36e2ab
10 changed files with 3393 additions and 92 deletions

View File

@ -1,7 +1,8 @@
"""
Accessibility (WCAG 2.1 AA) Checker Engine (F-003).
Accessibility (WCAG) Checker Engine (F-003).
Uses Playwright + axe-core for comprehensive accessibility testing.
Falls back to BeautifulSoup-based checks if Playwright is unavailable.
Supports WCAG version selection (2.0/2.1/2.2) via rules/accessibility.yaml.
"""
import json
@ -14,6 +15,7 @@ from bs4 import BeautifulSoup
from app.engines.base import BaseChecker
from app.models.schemas import CategoryResult, Issue
from app.rules import get_rules
logger = logging.getLogger(__name__)
@ -24,21 +26,42 @@ AXE_CORE_JS_PATH = Path(__file__).parent / "axe_core" / "axe.min.js"
AXE_RULE_MESSAGES = {
"image-alt": ("A-01", "이미지에 대체 텍스트(alt)가 없습니다", "1.1.1"),
"color-contrast": ("A-02", "텍스트와 배경의 색상 대비가 부족합니다", "1.4.3"),
"color-contrast-enhanced": ("A-02", "텍스트와 배경의 색상 대비가 향상된 기준을 충족하지 않습니다", "1.4.6"),
"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"),
"input-button-name": ("A-05", "입력 버튼에 접근 가능한 이름이 없습니다", "4.1.2"),
"select-name": ("A-05", "select 요소에 접근 가능한 이름이 없습니다", "4.1.2"),
"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"),
"aria-allowed-attr": ("A-06", "허용되지 않는 ARIA 속성이 사용되었습니다", "4.1.2"),
"aria-allowed-role": ("A-06", "허용되지 않는 ARIA 역할이 사용되었습니다", "4.1.2"),
"aria-hidden-body": ("A-06", "body 요소에 aria-hidden이 설정되어 있습니다", "4.1.2"),
"aria-hidden-focus": ("A-06", "aria-hidden 요소 내에 포커스 가능한 요소가 있습니다", "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"),
"valid-lang": ("A-08", "lang 속성 값이 올바르지 않습니다", "3.1.2"),
"bypass": ("A-09", "건너뛰기 링크(skip navigation)가 없습니다", "2.4.1"),
"region": ("A-09", "랜드마크 영역 밖에 콘텐츠가 있습니다", "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"),
"document-title": ("A-11", "페이지에 제목(title)이 없습니다", "2.4.2"),
"empty-heading": ("A-12", "빈 heading 요소가 있습니다", "2.4.6"),
"frame-title": ("A-13", "iframe에 접근 가능한 제목이 없습니다", "4.1.2"),
"button-name": ("A-14", "버튼에 접근 가능한 이름이 없습니다", "4.1.2"),
"meta-refresh": ("A-15", "meta refresh로 시간 제한이 설정되어 있습니다", "2.2.1"),
"meta-viewport-large": ("A-16", "사용자의 확대/축소가 제한되어 있습니다", "1.4.4"),
"autocomplete-valid": ("A-17", "autocomplete 속성이 올바르지 않습니다", "1.3.5"),
"target-size": ("A-18", "터치 대상 크기가 최소 기준을 충족하지 않습니다", "2.5.8"),
"scrollable-region-focusable": ("A-19", "스크롤 가능한 영역에 키보드 접근이 불가합니다", "2.1.1"),
"tabindex": ("A-20", "tabindex 값이 0보다 크게 설정되어 있습니다", "2.4.3"),
"blink": ("A-21", "깜빡이는 콘텐츠가 있습니다", "2.2.2"),
"marquee": ("A-21", "자동 스크롤 콘텐츠(marquee)가 있습니다", "2.2.2"),
}
# axe-core impact to severity mapping
@ -49,9 +72,56 @@ IMPACT_TO_SEVERITY = {
"minor": "info",
}
# WCAG version to axe-core tags mapping (loaded from YAML at runtime)
WCAG_VERSION_PRESETS = {
"2.0_A": ["wcag2a"],
"2.0_AA": ["wcag2a", "wcag2aa"],
"2.1_AA": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
"2.2_AA": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
"2.2_full": ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag22aa"],
}
def _get_axe_tags_for_version(wcag_version: str = "2.1_AA") -> list[str]:
"""Get axe-core tags for a given WCAG version preset."""
rules = get_rules("accessibility")
presets = rules.get("compliance_presets", {})
# Map user-friendly names to YAML preset keys
version_map = {
"2.0_A": "wcag_20_a",
"2.0_AA": "wcag_20_aa",
"2.1_AA": "wcag_21_aa",
"2.2_AA": "wcag_22_aa",
"2.2_full": "wcag_22_full",
}
yaml_key = version_map.get(wcag_version)
if yaml_key and yaml_key in presets:
return presets[yaml_key].get("tags", WCAG_VERSION_PRESETS.get(wcag_version, ["wcag2a", "wcag2aa"]))
return WCAG_VERSION_PRESETS.get(wcag_version, ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
def _get_wcag_level_label(wcag_version: str) -> str:
"""Get human-readable WCAG level label."""
labels = {
"2.0_A": "WCAG 2.0 Level A",
"2.0_AA": "WCAG 2.0 Level AA",
"2.1_AA": "WCAG 2.1 Level AA",
"2.2_AA": "WCAG 2.2 Level AA",
"2.2_full": "WCAG 2.2 All Levels",
}
return labels.get(wcag_version, "WCAG 2.1 Level AA")
class AccessibilityChecker(BaseChecker):
"""Accessibility (WCAG 2.1 AA) checker engine."""
"""Accessibility (WCAG) checker engine with version selection."""
def __init__(self, progress_callback=None, wcag_version: str = "2.1_AA"):
super().__init__(progress_callback)
self.wcag_version = wcag_version
self.axe_tags = _get_axe_tags_for_version(wcag_version)
@property
def category_name(self) -> str:
@ -86,12 +156,10 @@ class AccessibilityChecker(BaseChecker):
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');
@ -104,22 +172,27 @@ class AccessibilityChecker(BaseChecker):
}
""")
await self.update_progress(60, "접근성 검사 실행 중...")
axe_results = await page.evaluate("""
() => {
return new Promise((resolve, reject) => {
if (typeof axe === 'undefined') {
await self.update_progress(60, f"접근성 검사 실행 중 ({_get_wcag_level_label(self.wcag_version)})...")
# Build axe-core tag list including best-practice
axe_tag_list = self.axe_tags + ["best-practice"]
tags_js = json.dumps(axe_tag_list)
axe_results = await page.evaluate(f"""
() => {{
return new Promise((resolve, reject) => {{
if (typeof axe === 'undefined') {{
reject(new Error('axe-core not loaded'));
return;
}
axe.run(document, {
runOnly: {
}}
axe.run(document, {{
runOnly: {{
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'best-practice']
}
}).then(resolve).catch(reject);
});
}
values: {tags_js}
}}
}}).then(resolve).catch(reject);
}});
}}
""")
await self.update_progress(80, "결과 분석 중...")
@ -134,7 +207,7 @@ class AccessibilityChecker(BaseChecker):
category="accessibility",
score=score,
issues=issues,
wcag_level="AA",
wcag_level=_get_wcag_level_label(self.wcag_version),
)
async def _check_with_beautifulsoup(self, url: str, html_content: str) -> CategoryResult:
@ -170,7 +243,7 @@ class AccessibilityChecker(BaseChecker):
category="accessibility",
score=score,
issues=issues,
wcag_level="AA",
wcag_level=_get_wcag_level_label(self.wcag_version),
)
def _parse_axe_results(self, axe_results: dict) -> list[Issue]:
@ -182,15 +255,15 @@ class AccessibilityChecker(BaseChecker):
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"
code = "A-99"
korean_msg = violation.get("description", "접근성 위반 사항이 발견되었습니다")
wcag = "4.1.2"
# Try to extract WCAG criterion from tags
tags = violation.get("tags", [])
wcag = self._extract_wcag_from_tags(tags)
# Get affected elements
nodes = violation.get("nodes", [])
element = None
if nodes:
@ -211,7 +284,6 @@ class AccessibilityChecker(BaseChecker):
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 ""
@ -226,11 +298,19 @@ class AccessibilityChecker(BaseChecker):
return issues
@staticmethod
def _extract_wcag_from_tags(tags: list[str]) -> str:
"""Extract WCAG criterion number from axe-core tags (e.g., 'wcag111' -> '1.1.1')."""
for tag in tags:
if tag.startswith("wcag") and not tag.startswith("wcag2"):
# e.g., "wcag111" -> "1.1.1"
digits = tag[4:]
if len(digits) >= 3:
return f"{digits[0]}.{digits[1]}.{digits[2:]}"
return "4.1.2"
def _calculate_axe_score(self, axe_results: dict) -> int:
"""
Calculate score based on axe-core violations.
critical=-20, serious=-10, moderate=-5, minor=-2
"""
"""Calculate score based on axe-core violations."""
severity_weights = {
"critical": 20,
"serious": 10,
@ -284,7 +364,6 @@ class AccessibilityChecker(BaseChecker):
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
@ -377,10 +456,9 @@ class AccessibilityChecker(BaseChecker):
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
for link in skip_links[:10]:
href = link.get("href", "")
text = link.get_text(strip=True).lower()
if href.startswith("#") and any(
@ -417,6 +495,6 @@ class AccessibilityChecker(BaseChecker):
suggestion="autoplay 미디어에 controls 속성을 추가하거나 muted 속성을 사용하세요",
wcag_criterion="1.4.2",
))
break # Report only first
break
return issues