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:
@ -2,6 +2,7 @@
|
||||
HTML/CSS Standards Checker Engine (F-002).
|
||||
Checks HTML5 validity, semantic tags, CSS inline usage, etc.
|
||||
Uses BeautifulSoup4 + html5lib for parsing.
|
||||
Rules loaded from rules/html_css.yaml.
|
||||
"""
|
||||
|
||||
import re
|
||||
@ -13,15 +14,28 @@ 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__)
|
||||
|
||||
DEPRECATED_TAGS = [
|
||||
"font", "center", "marquee", "blink", "strike", "big", "tt",
|
||||
"basefont", "applet", "dir", "isindex",
|
||||
]
|
||||
|
||||
SEMANTIC_TAGS = ["header", "nav", "main", "footer", "section", "article"]
|
||||
def _load_obsolete_elements() -> list[dict]:
|
||||
"""Load obsolete elements from YAML."""
|
||||
rules = get_rules("html_css")
|
||||
return rules.get("obsolete_elements", [])
|
||||
|
||||
|
||||
def _load_obsolete_attributes() -> dict[str, list[dict]]:
|
||||
"""Load obsolete attributes from YAML, keyed by element name."""
|
||||
rules = get_rules("html_css")
|
||||
return rules.get("obsolete_attributes", {})
|
||||
|
||||
|
||||
def _load_semantic_tags() -> list[str]:
|
||||
"""Load structural semantic tag names from YAML."""
|
||||
rules = get_rules("html_css")
|
||||
structural = rules.get("semantic_elements", {}).get("structural", [])
|
||||
return [item["tag"] for item in structural]
|
||||
|
||||
|
||||
class HtmlCssChecker(BaseChecker):
|
||||
@ -50,16 +64,21 @@ class HtmlCssChecker(BaseChecker):
|
||||
await self.update_progress(50, "시맨틱 태그 검사 중...")
|
||||
issues += self._check_semantic_tags(soup)
|
||||
|
||||
await self.update_progress(60, "이미지 alt 속성 검사 중...")
|
||||
await self.update_progress(55, "이미지 alt 속성 검사 중...")
|
||||
issues += self._check_img_alt(soup)
|
||||
|
||||
await self.update_progress(70, "중복 ID 검사 중...")
|
||||
await self.update_progress(60, "중복 ID 검사 중...")
|
||||
issues += self._check_duplicate_ids(soup)
|
||||
|
||||
await self.update_progress(80, "링크 및 스타일 검사 중...")
|
||||
await self.update_progress(65, "링크 및 스타일 검사 중...")
|
||||
issues += self._check_empty_links(soup)
|
||||
issues += self._check_inline_styles(soup)
|
||||
issues += self._check_deprecated_tags(soup)
|
||||
|
||||
await self.update_progress(75, "Obsolete 태그 검사 중...")
|
||||
issues += self._check_obsolete_tags(soup)
|
||||
|
||||
await self.update_progress(80, "Obsolete 속성 검사 중...")
|
||||
issues += self._check_obsolete_attributes(soup)
|
||||
|
||||
await self.update_progress(90, "heading 구조 검사 중...")
|
||||
issues += self._check_heading_hierarchy(soup)
|
||||
@ -134,21 +153,22 @@ class HtmlCssChecker(BaseChecker):
|
||||
|
||||
def _check_semantic_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-05: Check for semantic HTML5 tag usage."""
|
||||
semantic_tags = _load_semantic_tags()
|
||||
found_tags = set()
|
||||
for tag_name in SEMANTIC_TAGS:
|
||||
for tag_name in semantic_tags:
|
||||
if soup.find(tag_name):
|
||||
found_tags.add(tag_name)
|
||||
|
||||
if not found_tags:
|
||||
tag_list = ", ".join(semantic_tags)
|
||||
return [self._create_issue(
|
||||
code="H-05",
|
||||
severity="minor",
|
||||
message="시맨틱 태그가 사용되지 않았습니다 (header, nav, main, footer, section, article)",
|
||||
message=f"시맨틱 태그가 사용되지 않았습니다 ({tag_list})",
|
||||
suggestion="적절한 시맨틱 태그를 사용하여 문서 구조를 명확히 하세요",
|
||||
)]
|
||||
|
||||
missing = set(SEMANTIC_TAGS) - found_tags
|
||||
# Only report if major structural elements are missing (main is most important)
|
||||
missing = set(semantic_tags) - found_tags
|
||||
if "main" in missing:
|
||||
return [self._create_issue(
|
||||
code="H-05",
|
||||
@ -240,23 +260,105 @@ class HtmlCssChecker(BaseChecker):
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_deprecated_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-10: Check for deprecated HTML tags."""
|
||||
def _check_obsolete_tags(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-10: Check for obsolete HTML tags (loaded from YAML)."""
|
||||
issues = []
|
||||
for tag_name in DEPRECATED_TAGS:
|
||||
obsolete = _load_obsolete_elements()
|
||||
|
||||
for entry in obsolete:
|
||||
tag_name = entry["tag"]
|
||||
found = soup.find_all(tag_name)
|
||||
if found:
|
||||
replacement = entry.get("replacement", "CSS")
|
||||
severity = entry.get("severity", "major")
|
||||
first_el = found[0]
|
||||
issues.append(self._create_issue(
|
||||
code="H-10",
|
||||
severity="major",
|
||||
message=f"사용 중단된(deprecated) 태그 <{tag_name}>이(가) {len(found)}회 사용되었습니다",
|
||||
severity=severity,
|
||||
message=f"사용 중단된(obsolete) 태그 <{tag_name}>이(가) {len(found)}회 사용되었습니다",
|
||||
element=self._truncate_element(str(first_el)),
|
||||
line=self._get_line_number(first_el),
|
||||
suggestion=f"<{tag_name}> 대신 CSS를 사용하여 스타일을 적용하세요",
|
||||
suggestion=f"<{tag_name}> 대신 {replacement}을(를) 사용하세요",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_obsolete_attributes(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-13: Check for obsolete HTML attributes (loaded from YAML)."""
|
||||
issues = []
|
||||
obsolete_attrs = _load_obsolete_attributes()
|
||||
|
||||
# Check element-specific obsolete attributes
|
||||
element_checks = {
|
||||
"a": "a", "body": "body", "br": "br", "form": "form",
|
||||
"hr": "hr", "html": "html", "iframe": "iframe", "img": "img",
|
||||
"input": "input", "link": "link", "meta": "meta", "script": "script",
|
||||
"style": "style", "table": "table", "embed": "embed",
|
||||
}
|
||||
# Multi-element groups
|
||||
multi_checks = {
|
||||
"td_th": ["td", "th"],
|
||||
"tr": ["tr"],
|
||||
"thead_tbody_tfoot": ["thead", "tbody", "tfoot"],
|
||||
"ol_ul": ["ol", "ul"],
|
||||
"heading": ["h1", "h2", "h3", "h4", "h5", "h6"],
|
||||
"embed": ["embed"],
|
||||
}
|
||||
|
||||
found_count = 0
|
||||
first_element = None
|
||||
first_line = None
|
||||
first_attr = None
|
||||
|
||||
# Single-element checks
|
||||
for yaml_key, html_tag in element_checks.items():
|
||||
attr_list = obsolete_attrs.get(yaml_key, [])
|
||||
for attr_entry in attr_list:
|
||||
attr_name = attr_entry["attr"]
|
||||
elements = soup.find_all(html_tag, attrs={attr_name: True})
|
||||
if elements:
|
||||
found_count += len(elements)
|
||||
if first_element is None:
|
||||
first_element = self._truncate_element(str(elements[0]))
|
||||
first_line = self._get_line_number(elements[0])
|
||||
first_attr = f'{html_tag}[{attr_name}]'
|
||||
|
||||
# Multi-element group checks
|
||||
for yaml_key, html_tags in multi_checks.items():
|
||||
attr_list = obsolete_attrs.get(yaml_key, [])
|
||||
for attr_entry in attr_list:
|
||||
attr_name = attr_entry["attr"]
|
||||
for html_tag in html_tags:
|
||||
elements = soup.find_all(html_tag, attrs={attr_name: True})
|
||||
if elements:
|
||||
found_count += len(elements)
|
||||
if first_element is None:
|
||||
first_element = self._truncate_element(str(elements[0]))
|
||||
first_line = self._get_line_number(elements[0])
|
||||
first_attr = f'{html_tag}[{attr_name}]'
|
||||
|
||||
# Global obsolete attributes
|
||||
global_attrs = obsolete_attrs.get("global", [])
|
||||
for attr_entry in global_attrs:
|
||||
attr_name = attr_entry["attr"]
|
||||
elements = soup.find_all(attrs={attr_name: True})
|
||||
if elements:
|
||||
found_count += len(elements)
|
||||
if first_element is None:
|
||||
first_element = self._truncate_element(str(elements[0]))
|
||||
first_line = self._get_line_number(elements[0])
|
||||
first_attr = attr_name
|
||||
|
||||
if found_count > 0:
|
||||
issues.append(self._create_issue(
|
||||
code="H-13",
|
||||
severity="minor",
|
||||
message=f"사용 중단된(obsolete) HTML 속성이 {found_count}개 발견되었습니다 (예: {first_attr})",
|
||||
element=first_element,
|
||||
line=first_line,
|
||||
suggestion="사용 중단된 HTML 속성 대신 CSS를 사용하세요 (W3C HTML Living Standard 참조)",
|
||||
))
|
||||
return issues
|
||||
|
||||
def _check_heading_hierarchy(self, soup: BeautifulSoup) -> list[Issue]:
|
||||
"""H-11: Check heading hierarchy (h1-h6 should not skip levels)."""
|
||||
issues = []
|
||||
@ -277,7 +379,7 @@ class HtmlCssChecker(BaseChecker):
|
||||
line=self._get_line_number(heading),
|
||||
suggestion=f"h{prev_level} 다음에는 h{prev_level + 1}을 사용하세요",
|
||||
))
|
||||
break # Only report first skip
|
||||
break
|
||||
prev_level = level
|
||||
return issues
|
||||
|
||||
@ -295,14 +397,12 @@ class HtmlCssChecker(BaseChecker):
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user