Files
our-claude-skills/custom-skills/34-seo-reporting-dashboard/code/scripts/executive_report.py

623 lines
22 KiB
Python

"""
Executive Report - Korean-language executive summary generation
===============================================================
Purpose: Generate stakeholder-ready executive summaries in Korean from
aggregated SEO report data, with audience-specific detail levels
for C-level, marketing, and technical teams.
Python: 3.10+
Usage:
python executive_report.py --report aggregated_report.json --audience c-level --output report.md
python executive_report.py --report aggregated_report.json --audience marketing --output report.md
python executive_report.py --report aggregated_report.json --audience technical --output report.md
python executive_report.py --report aggregated_report.json --audience c-level --format notion
"""
import argparse
import json
import logging
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class AudienceConfig:
"""Configuration for report audience targeting."""
level: str = "c-level" # c-level | marketing | technical
detail_depth: str = "summary" # summary | moderate | detailed
include_recommendations: bool = True
include_technical_details: bool = False
max_issues: int = 5
max_recommendations: int = 5
@classmethod
def from_level(cls, level: str) -> "AudienceConfig":
"""Create config preset from audience level."""
presets = {
"c-level": cls(
level="c-level",
detail_depth="summary",
include_recommendations=True,
include_technical_details=False,
max_issues=5,
max_recommendations=3,
),
"marketing": cls(
level="marketing",
detail_depth="moderate",
include_recommendations=True,
include_technical_details=False,
max_issues=10,
max_recommendations=5,
),
"technical": cls(
level="technical",
detail_depth="detailed",
include_recommendations=True,
include_technical_details=True,
max_issues=20,
max_recommendations=10,
),
}
return presets.get(level, presets["c-level"])
@dataclass
class ExecutiveSummary:
"""Generated executive summary content."""
title: str = ""
domain: str = ""
period: str = ""
health_score: float = 0.0
health_trend: str = "stable"
key_wins: list[str] = field(default_factory=list)
key_concerns: list[str] = field(default_factory=list)
recommendations: list[str] = field(default_factory=list)
narrative: str = ""
audience: str = "c-level"
category_summary: dict[str, str] = field(default_factory=dict)
audit_id: str = ""
timestamp: str = ""
# ---------------------------------------------------------------------------
# Korean text templates
# ---------------------------------------------------------------------------
HEALTH_LABELS_KR = {
"excellent": "우수",
"good": "양호",
"average": "보통",
"poor": "미흡",
"critical": "위험",
}
TREND_LABELS_KR = {
"improving": "개선 중",
"stable": "안정",
"declining": "하락 중",
}
CATEGORY_LABELS_KR = {
"technical": "기술 SEO",
"on_page": "온페이지 SEO",
"performance": "성능 (Core Web Vitals)",
"content": "콘텐츠 전략",
"links": "링크 프로필",
"local": "로컬 SEO",
"keywords": "키워드 전략",
"competitor": "경쟁 분석",
"schema": "스키마/구조화 데이터",
"kpi": "KPI 프레임워크",
"search_console": "서치 콘솔",
"ecommerce": "이커머스 SEO",
"international": "국제 SEO",
"ai_search": "AI 검색 가시성",
"entity_seo": "지식 그래프",
}
# Common English issue descriptions -> Korean translations
ISSUE_TRANSLATIONS_KR: dict[str, str] = {
"missing meta description": "메타 설명(meta description) 누락",
"missing title tag": "타이틀 태그 누락",
"duplicate title": "중복 타이틀 태그",
"duplicate meta description": "중복 메타 설명",
"missing h1": "H1 태그 누락",
"multiple h1 tags": "H1 태그 다수 사용",
"missing alt text": "이미지 alt 텍스트 누락",
"broken links": "깨진 링크 발견",
"redirect chain": "리다이렉트 체인 발견",
"mixed content": "Mixed Content (HTTP/HTTPS 혼합) 발견",
"missing canonical": "Canonical 태그 누락",
"noindex on important page": "중요 페이지에 noindex 설정됨",
"slow page load": "페이지 로딩 속도 저하",
"cls exceeds threshold": "CLS(누적 레이아웃 변경) 임계값 초과",
"lcp exceeds threshold": "LCP(최대 콘텐츠풀 페인트) 임계값 초과",
"missing sitemap": "사이트맵 누락",
"robots.txt blocking important pages": "robots.txt에서 중요 페이지 차단 중",
"missing schema markup": "스키마 마크업 누락",
"missing hreflang": "hreflang 태그 누락",
"thin content": "콘텐츠 부족 (Thin Content)",
"orphan pages": "고아 페이지 발견 (내부 링크 없음)",
}
def _translate_description(desc: str) -> str:
"""Translate common English issue descriptions to Korean."""
desc_lower = desc.lower().strip()
# Check exact match
if desc_lower in ISSUE_TRANSLATIONS_KR:
return ISSUE_TRANSLATIONS_KR[desc_lower]
# Check partial match (case-insensitive replace)
for eng, kor in ISSUE_TRANSLATIONS_KR.items():
if eng in desc_lower:
# Find the original-case substring and replace it
idx = desc_lower.index(eng)
return desc[:idx] + kor + desc[idx + len(eng):]
return desc
AUDIENCE_INTRO_KR = {
"c-level": "본 보고서는 SEO 성과의 핵심 지표와 비즈니스 영향을 요약한 경영진용 보고서입니다.",
"marketing": "본 보고서는 SEO 전략 실행 현황과 마케팅 성과를 분석한 마케팅팀 보고서입니다.",
"technical": "본 보고서는 SEO 기술 진단 결과와 상세 개선 사항을 포함한 기술팀 보고서입니다.",
}
# ---------------------------------------------------------------------------
# Generator
# ---------------------------------------------------------------------------
class ExecutiveReportGenerator:
"""Generate Korean-language executive reports from aggregated SEO data."""
@staticmethod
def _health_grade(score: float) -> str:
"""Return health grade string."""
if score >= 90:
return "excellent"
elif score >= 75:
return "good"
elif score >= 60:
return "average"
elif score >= 40:
return "poor"
else:
return "critical"
def generate_narrative(
self,
report: dict[str, Any],
audience: AudienceConfig,
) -> str:
"""Generate Korean narrative text for the executive summary."""
domain = report.get("domain", "")
health = report.get("overall_health", 0)
trend = report.get("health_trend", "stable")
grade = self._health_grade(health)
grade_kr = HEALTH_LABELS_KR.get(grade, grade)
trend_kr = TREND_LABELS_KR.get(trend, trend)
intro = AUDIENCE_INTRO_KR.get(audience.level, AUDIENCE_INTRO_KR["c-level"])
# Build narrative paragraphs
paragraphs = []
# Opening
paragraphs.append(intro)
# Health overview
paragraphs.append(
f"{domain}의 전체 SEO Health Score는 **{health}/100** ({grade_kr})이며, "
f"현재 추세는 **{trend_kr}** 상태입니다."
)
# Category highlights
cat_scores = report.get("category_scores", {})
if cat_scores:
strong_cats = [
CATEGORY_LABELS_KR.get(k, k)
for k, v in cat_scores.items()
if v >= 75
]
weak_cats = [
CATEGORY_LABELS_KR.get(k, k)
for k, v in cat_scores.items()
if v < 50
]
if strong_cats:
paragraphs.append(
f"강점 영역: {', '.join(strong_cats[:3])} 등이 양호한 성과를 보이고 있습니다."
)
if weak_cats:
paragraphs.append(
f"개선 필요 영역: {', '.join(weak_cats[:3])} 등에서 집중적인 개선이 필요합니다."
)
# Skills coverage
skills = report.get("skills_included", [])
if skills:
paragraphs.append(
f"{len(skills)}개의 SEO 진단 도구를 통해 종합 분석을 수행하였습니다."
)
# C-level specific: business impact focus
if audience.level == "c-level":
if trend == "improving":
paragraphs.append(
"전반적인 SEO 성과가 개선 추세에 있으며, 현재 전략을 유지하면서 "
"핵심 약점 영역에 대한 집중 투자가 권장됩니다."
)
elif trend == "declining":
paragraphs.append(
"SEO 성과가 하락 추세를 보이고 있어, 원인 분석과 함께 "
"긴급한 대응 조치가 필요합니다."
)
else:
paragraphs.append(
"SEO 성과가 안정적으로 유지되고 있으나, 경쟁 환경 변화에 대비하여 "
"지속적인 모니터링과 선제적 대응이 필요합니다."
)
# Marketing specific: channel and content focus
elif audience.level == "marketing":
top_issues = report.get("top_issues", [])
content_issues = [
i for i in top_issues if i.get("category") in ("content", "keywords")
]
if content_issues:
paragraphs.append(
f"콘텐츠/키워드 관련 이슈가 {len(content_issues)}건 발견되었으며, "
f"콘텐츠 전략 수정이 권장됩니다."
)
# Technical specific: detailed breakdown
elif audience.level == "technical":
for cat, score in sorted(
cat_scores.items(), key=lambda x: x[1]
):
cat_kr = CATEGORY_LABELS_KR.get(cat, cat)
paragraphs.append(f"- {cat_kr}: {score}/100")
return "\n\n".join(paragraphs)
def format_wins(self, report: dict[str, Any]) -> list[str]:
"""Extract and format key wins in Korean."""
wins = report.get("top_wins", [])
formatted: list[str] = []
for win in wins:
desc = _translate_description(win.get("description", ""))
cat = win.get("category", "")
cat_kr = CATEGORY_LABELS_KR.get(cat, cat)
if desc:
formatted.append(f"[{cat_kr}] {desc}")
return formatted
def format_concerns(self, report: dict[str, Any]) -> list[str]:
"""Extract and format key concerns in Korean."""
issues = report.get("top_issues", [])
formatted: list[str] = []
severity_kr = {
"critical": "긴급",
"high": "높음",
"medium": "보통",
"low": "낮음",
}
for issue in issues:
desc = _translate_description(issue.get("description", ""))
severity = issue.get("severity", "medium")
cat = issue.get("category", "")
sev_kr = severity_kr.get(severity, severity)
cat_kr = CATEGORY_LABELS_KR.get(cat, cat)
if desc:
formatted.append(f"[{sev_kr}] [{cat_kr}] {desc}")
return formatted
def generate_recommendations(
self,
report: dict[str, Any],
audience: AudienceConfig,
) -> list[str]:
"""Generate prioritized action items ranked by impact."""
recommendations: list[str] = []
cat_scores = report.get("category_scores", {})
top_issues = report.get("top_issues", [])
# Priority 1: Critical issues
critical = [i for i in top_issues if i.get("severity") == "critical"]
for issue in critical[:3]:
cat_kr = CATEGORY_LABELS_KR.get(issue.get("category", ""), "")
desc = _translate_description(issue.get("description", ""))
if audience.level == "c-level":
recommendations.append(
f"[긴급] {cat_kr} 영역 긴급 조치 필요 - {desc}"
)
else:
recommendations.append(
f"[긴급] {desc} (영역: {cat_kr})"
)
# Priority 2: Weak categories
weak_cats = sorted(
[(k, v) for k, v in cat_scores.items() if v < 50],
key=lambda x: x[1],
)
for cat, score in weak_cats[:3]:
cat_kr = CATEGORY_LABELS_KR.get(cat, cat)
if audience.level == "c-level":
recommendations.append(
f"[개선] {cat_kr} 점수 {score}/100 - 전략적 투자 권장"
)
elif audience.level == "marketing":
recommendations.append(
f"[개선] {cat_kr} ({score}/100) - 캠페인 전략 재검토 필요"
)
else:
recommendations.append(
f"[개선] {cat_kr} ({score}/100) - 상세 진단 및 기술적 개선 필요"
)
# Priority 3: Maintenance for good categories
strong_cats = [
(k, v) for k, v in cat_scores.items() if v >= 75
]
if strong_cats:
cats_kr = ", ".join(
CATEGORY_LABELS_KR.get(k, k) for k, _ in strong_cats[:3]
)
recommendations.append(
f"[유지] {cats_kr} - 현재 수준 유지 및 모니터링 지속"
)
# Audience-specific recommendations
if audience.level == "c-level":
health = report.get("overall_health", 0)
if health < 60:
recommendations.append(
"[전략] SEO 개선을 위한 전문 인력 또는 외부 에이전시 투입 검토"
)
elif audience.level == "marketing":
recommendations.append(
"[실행] 다음 분기 SEO 개선 로드맵 수립 및 KPI 설정"
)
elif audience.level == "technical":
recommendations.append(
"[실행] 기술 부채 해소 스프린트 계획 수립"
)
return recommendations[:audience.max_recommendations]
def render_markdown(self, summary: ExecutiveSummary) -> str:
"""Render executive summary as markdown document."""
lines: list[str] = []
# Title
lines.append(f"# {summary.title}")
lines.append("")
# Meta
audience_kr = {
"c-level": "경영진",
"marketing": "마케팅팀",
"technical": "기술팀",
}
lines.append(f"**대상**: {audience_kr.get(summary.audience, summary.audience)}")
lines.append(f"**도메인**: {summary.domain}")
lines.append(f"**보고 일자**: {summary.period}")
lines.append(f"**Audit ID**: {summary.audit_id}")
lines.append("")
# Health Score
grade = self._health_grade(summary.health_score)
grade_kr = HEALTH_LABELS_KR.get(grade, grade)
trend_kr = TREND_LABELS_KR.get(summary.health_trend, summary.health_trend)
lines.append("## 종합 건강 점수")
lines.append("")
lines.append(f"| 지표 | 값 |")
lines.append(f"|------|-----|")
lines.append(f"| 종합 점수 | **{summary.health_score}/100** |")
lines.append(f"| 등급 | {grade_kr} |")
lines.append(f"| 추세 | {trend_kr} |")
lines.append("")
# Category summary
if summary.category_summary:
lines.append("## 영역별 점수")
lines.append("")
lines.append("| 영역 | 점수 |")
lines.append("|------|------|")
for cat, score_str in summary.category_summary.items():
cat_kr = CATEGORY_LABELS_KR.get(cat, cat)
lines.append(f"| {cat_kr} | {score_str} |")
lines.append("")
# Narrative
lines.append("## 종합 분석")
lines.append("")
lines.append(summary.narrative)
lines.append("")
# Key wins
if summary.key_wins:
lines.append("## 주요 성과")
lines.append("")
for win in summary.key_wins:
lines.append(f"- {win}")
lines.append("")
# Key concerns
if summary.key_concerns:
lines.append("## 주요 이슈")
lines.append("")
for concern in summary.key_concerns:
lines.append(f"- {concern}")
lines.append("")
# Recommendations
if summary.recommendations:
lines.append("## 권장 조치 사항")
lines.append("")
for i, rec in enumerate(summary.recommendations, 1):
lines.append(f"{i}. {rec}")
lines.append("")
# Footer
lines.append("---")
lines.append(
f"*이 보고서는 SEO Reporting Dashboard (Skill 34)에 의해 "
f"{summary.timestamp}에 자동 생성되었습니다.*"
)
return "\n".join(lines)
def run(
self,
report_json: str,
audience_level: str = "c-level",
output_path: str | None = None,
output_format: str = "markdown",
) -> str:
"""Orchestrate executive report generation."""
# Load report
report_path = Path(report_json)
if not report_path.exists():
raise FileNotFoundError(f"Report file not found: {report_json}")
report = json.loads(report_path.read_text(encoding="utf-8"))
logger.info(f"Loaded report: {report.get('domain', 'unknown')}")
# Configure audience
audience = AudienceConfig.from_level(audience_level)
logger.info(f"Audience: {audience.level} (depth: {audience.detail_depth})")
# Build summary
domain = report.get("domain", "")
summary = ExecutiveSummary(
title=f"SEO 성과 보고서 - {domain}",
domain=domain,
period=report.get("report_date", ""),
health_score=report.get("overall_health", 0),
health_trend=report.get("health_trend", "stable"),
audit_id=report.get("audit_id", ""),
audience=audience.level,
timestamp=datetime.now().isoformat(),
)
# Category summary
cat_scores = report.get("category_scores", {})
summary.category_summary = {
cat: f"{score}/100"
for cat, score in sorted(
cat_scores.items(), key=lambda x: x[1], reverse=True
)
}
# Generate content
summary.narrative = self.generate_narrative(report, audience)
summary.key_wins = self.format_wins(report)[:audience.max_issues]
summary.key_concerns = self.format_concerns(report)[:audience.max_issues]
summary.recommendations = self.generate_recommendations(report, audience)
# Render
if output_format == "markdown":
content = self.render_markdown(summary)
elif output_format == "notion":
# For Notion, we output markdown that can be pasted into Notion
content = self.render_markdown(summary)
logger.info(
"Notion format: use MCP tools to push this markdown to Notion "
f"database {report.get('audit_id', 'DASH-YYYYMMDD-NNN')}"
)
else:
content = self.render_markdown(summary)
# Save or print
if output_path:
Path(output_path).write_text(content, encoding="utf-8")
logger.info(f"Executive report saved to {output_path}")
else:
print(content)
return content
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="SEO Executive Report - Korean-language executive summary generator",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
python executive_report.py --report aggregated_report.json --audience c-level --output report.md
python executive_report.py --report aggregated_report.json --audience marketing --output report.md
python executive_report.py --report aggregated_report.json --audience technical --format notion
""",
)
parser.add_argument(
"--report",
required=True,
help="Path to aggregated report JSON file (from report_aggregator.py)",
)
parser.add_argument(
"--audience",
choices=["c-level", "marketing", "technical"],
default="c-level",
help="Target audience level (default: c-level)",
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Output file path (prints to stdout if omitted)",
)
parser.add_argument(
"--format",
choices=["markdown", "notion"],
default="markdown",
dest="output_format",
help="Output format (default: markdown)",
)
return parser.parse_args(argv)
def main() -> None:
args = parse_args()
generator = ExecutiveReportGenerator()
generator.run(
report_json=args.report,
audience_level=args.audience,
output_path=args.output,
output_format=args.output_format,
)
if __name__ == "__main__":
main()