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

@ -7,13 +7,14 @@ import re
import json
import logging
from urllib.parse import urlparse, urljoin
from typing import Optional
from typing import Any, Optional
import httpx
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__)
@ -21,6 +22,23 @@ logger = logging.getLogger(__name__)
class SeoChecker(BaseChecker):
"""SEO optimization checker engine."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._rules_data = get_rules("seo")
def _get_seo_rule(self, rule_id: str) -> dict[str, Any]:
"""Lookup a rule by id from YAML data."""
for rule in self._rules_data.get("rules", []):
if rule.get("id") == rule_id:
return rule
return {}
def _get_threshold(self, rule_id: str, key: str, default: Any = None) -> Any:
"""Get a specific threshold from a rule's details."""
rule = self._get_seo_rule(rule_id)
details = rule.get("details", {})
return details.get(key, default)
@property
def category_name(self) -> str:
return "seo"
@ -73,9 +91,11 @@ class SeoChecker(BaseChecker):
)
def _check_title(self, soup: BeautifulSoup, meta_info: dict) -> list[Issue]:
"""S-01: Check title tag existence and length (10-60 chars)."""
"""S-01: Check title tag existence and length."""
issues = []
title = soup.find("title")
min_len = self._get_threshold("seo-title-tag", "min_length", 10)
max_len = self._get_threshold("seo-title-tag", "max_length", 60)
if title is None or not title.string or title.string.strip() == "":
meta_info["title"] = None
@ -84,7 +104,7 @@ class SeoChecker(BaseChecker):
code="S-01",
severity="critical",
message="<title> 태그가 없거나 비어있습니다",
suggestion="검색 결과에 표시될 10-60자 길이의 페이지 제목을 설정하세요",
suggestion=f"검색 결과에 표시될 {min_len}-{max_len}자 길이의 페이지 제목을 설정하세요",
))
return issues
@ -93,28 +113,30 @@ class SeoChecker(BaseChecker):
meta_info["title"] = title_text
meta_info["title_length"] = title_len
if title_len < 10:
if title_len < min_len:
issues.append(self._create_issue(
code="S-01",
severity="critical",
message=f"title이 너무 짧습니다 ({title_len}자, 권장 10-60자)",
message=f"title이 너무 짧습니다 ({title_len}자, 권장 {min_len}-{max_len}자)",
element=f"<title>{title_text}</title>",
suggestion="검색 결과에 효과적으로 표시되도록 10자 이상의 제목을 작성하세요",
suggestion=f"검색 결과에 효과적으로 표시되도록 {min_len}자 이상의 제목을 작성하세요",
))
elif title_len > 60:
elif title_len > max_len:
issues.append(self._create_issue(
code="S-01",
severity="minor",
message=f"title이 너무 깁니다 ({title_len}자, 권장 10-60자)",
message=f"title이 너무 깁니다 ({title_len}자, 권장 {min_len}-{max_len}자)",
element=f"<title>{title_text[:50]}...</title>",
suggestion="검색 결과에서 잘리지 않도록 60자 이내로 제목을 줄이세요",
suggestion=f"검색 결과에서 잘리지 않도록 {max_len}자 이내로 제목을 줄이세요",
))
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)."""
"""S-02: Check meta description existence and length."""
issues = []
desc = soup.find("meta", attrs={"name": re.compile(r"^description$", re.I)})
min_len = self._get_threshold("seo-meta-description", "min_length", 50)
max_len = self._get_threshold("seo-meta-description", "max_length", 160)
if desc is None or not desc.get("content"):
meta_info["description"] = None
@ -123,7 +145,7 @@ class SeoChecker(BaseChecker):
code="S-02",
severity="major",
message="meta description이 없습니다",
suggestion='<meta name="description" content="페이지 설명">을 추가하세요 (50-160자 권장)',
suggestion=f'<meta name="description" content="페이지 설명">을 추가하세요 ({min_len}-{max_len}자 권장)',
))
return issues
@ -132,19 +154,19 @@ class SeoChecker(BaseChecker):
meta_info["description"] = content
meta_info["description_length"] = content_len
if content_len < 50:
if content_len < min_len:
issues.append(self._create_issue(
code="S-02",
severity="major",
message=f"meta description이 너무 짧습니다 ({content_len}자, 권장 50-160자)",
suggestion="검색 결과에서 페이지를 효과적으로 설명하도록 50자 이상으로 작성하세요",
message=f"meta description이 너무 짧습니다 ({content_len}자, 권장 {min_len}-{max_len}자)",
suggestion=f"검색 결과에서 페이지를 효과적으로 설명하도록 {min_len}자 이상으로 작성하세요",
))
elif content_len > 160:
elif content_len > max_len:
issues.append(self._create_issue(
code="S-02",
severity="minor",
message=f"meta description이 너무 깁니다 ({content_len}자, 권장 50-160자)",
suggestion="검색 결과에서 잘리지 않도록 160자 이내로 줄이세요",
message=f"meta description이 너무 깁니다 ({content_len}자, 권장 {min_len}-{max_len}자)",
suggestion=f"검색 결과에서 잘리지 않도록 {max_len}자 이내로 줄이세요",
))
return issues
@ -163,9 +185,13 @@ class SeoChecker(BaseChecker):
return []
def _check_og_tags(self, soup: BeautifulSoup) -> list[Issue]:
"""S-04: Check Open Graph tags (og:title, og:description, og:image)."""
"""S-04: Check Open Graph tags from YAML rule definitions."""
issues = []
required_og = ["og:title", "og:description", "og:image"]
rule = self._get_seo_rule("seo-open-graph")
required_tags = rule.get("details", {}).get("required_tags", [])
required_og = [t["property"] for t in required_tags] if required_tags else [
"og:title", "og:description", "og:image",
]
missing = []
for prop in required_og: