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