"""
Dashboard Generator - Interactive HTML SEO dashboard with Chart.js
==================================================================
Purpose: Generate a self-contained HTML dashboard from aggregated SEO
report data, with responsive charts for health scores, traffic
trends, keyword rankings, issue breakdowns, and competitor radar.
Python: 3.10+
Usage:
python dashboard_generator.py --report aggregated_report.json --output dashboard.html
python dashboard_generator.py --report aggregated_report.json --output dashboard.html --title "My SEO Dashboard"
"""
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
from jinja2 import Template
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class DashboardConfig:
"""Configuration for dashboard generation."""
title: str = "SEO Reporting Dashboard"
domain: str = ""
date_range: str = ""
theme: str = "light"
chart_options: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# HTML template
# ---------------------------------------------------------------------------
DASHBOARD_TEMPLATE = """
{{ title }} - {{ domain }}
{{ overall_health }}
Overall Health Score
{{ trend_label }}
{% if has_competitor_data %}
{% endif %}
Top Issues ({{ issues_count }})
{% for issue in top_issues %}
-
{{ issue.severity }}
{{ issue.description }} ({{ issue.category }})
{% endfor %}
{% if top_wins %}
Top Wins ({{ wins_count }})
{% for win in top_wins %}
-
WIN
{{ win.description }} ({{ win.category }})
{% endfor %}
{% endif %}
Audit History
| Date |
Skill |
Category |
Score |
Issues |
{% for entry in timeline %}
| {{ entry.date }} |
{{ entry.skill }} |
{{ entry.category }} |
{{ entry.health_score }} |
{{ entry.issues_count }} |
{% endfor %}
"""
# ---------------------------------------------------------------------------
# Generator
# ---------------------------------------------------------------------------
CATEGORY_KOREAN_LABELS: dict[str, str] = {
"technical": "기술 SEO",
"on_page": "온페이지",
"performance": "성능",
"content": "콘텐츠",
"links": "링크",
"local": "로컬 SEO",
"keywords": "키워드",
"competitor": "경쟁사",
"schema": "스키마",
"kpi": "KPI",
"comprehensive": "종합 감사",
"search_console": "서치 콘솔",
"ecommerce": "이커머스",
"international": "국제 SEO",
"ai_search": "AI 검색",
"entity_seo": "엔티티 SEO",
"migration": "사이트 이전",
}
class DashboardGenerator:
"""Generate an interactive HTML dashboard from aggregated SEO report data."""
def __init__(self):
self.template = Template(DASHBOARD_TEMPLATE)
@staticmethod
def _score_class(score: float) -> str:
"""Return CSS class based on health score."""
if score >= 90:
return "score-excellent"
elif score >= 75:
return "score-good"
elif score >= 60:
return "score-average"
elif score >= 40:
return "score-poor"
else:
return "score-critical"
@staticmethod
def _gauge_color(score: float) -> str:
"""Return color hex for gauge chart."""
if score >= 90:
return "#198754"
elif score >= 75:
return "#20c997"
elif score >= 60:
return "#ffc107"
elif score >= 40:
return "#fd7e14"
else:
return "#dc3545"
@staticmethod
def _category_color(score: float) -> str:
"""Return color for category bar based on score."""
if score >= 80:
return "#198754"
elif score >= 60:
return "#0d6efd"
elif score >= 40:
return "#ffc107"
else:
return "#dc3545"
@staticmethod
def _trend_label(trend: str) -> str:
"""Return human-readable trend label in Korean."""
labels = {
"improving": "개선 중 ↑",
"stable": "안정 →",
"declining": "하락 중 ↓",
}
return labels.get(trend, trend.title())
def generate_health_gauge(self, score: float) -> dict[str, Any]:
"""Generate gauge chart data for health score."""
return {
"score": score,
"remainder": 100 - score,
"color": self._gauge_color(score),
"class": self._score_class(score),
}
def generate_traffic_chart(self, traffic_data: list[dict]) -> dict[str, Any]:
"""Generate line chart data for traffic trends."""
dates = [d.get("date", "") for d in traffic_data]
values = [d.get("traffic", 0) for d in traffic_data]
return {"labels": dates, "values": values}
def generate_keyword_chart(self, keyword_data: list[dict]) -> dict[str, Any]:
"""Generate bar chart data for keyword ranking distribution."""
labels = [d.get("range", "") for d in keyword_data]
values = [d.get("count", 0) for d in keyword_data]
return {"labels": labels, "values": values}
def generate_issues_chart(
self, issues_data: list[dict[str, Any]]
) -> dict[str, Any]:
"""Generate pie chart data for issue category distribution."""
category_counts: dict[str, int] = {}
for issue in issues_data:
cat = issue.get("category", "other")
category_counts[cat] = category_counts.get(cat, 0) + 1
sorted_cats = sorted(
category_counts.items(), key=lambda x: x[1], reverse=True
)
return {
"labels": [CATEGORY_KOREAN_LABELS.get(c[0], c[0]) for c in sorted_cats],
"values": [c[1] for c in sorted_cats],
}
def generate_competitor_radar(
self, competitor_data: dict[str, Any]
) -> dict[str, Any]:
"""Generate radar chart data for competitor comparison."""
labels = list(competitor_data.get("dimensions", []))
datasets = []
colors = [
"rgba(13, 110, 253, 0.5)",
"rgba(220, 53, 69, 0.5)",
"rgba(25, 135, 84, 0.5)",
]
border_colors = ["#0d6efd", "#dc3545", "#198754"]
for i, (domain, scores) in enumerate(
competitor_data.get("scores", {}).items()
):
datasets.append({
"label": domain,
"data": [scores.get(dim, 0) for dim in labels],
"backgroundColor": colors[i % len(colors)],
"borderColor": border_colors[i % len(border_colors)],
"borderWidth": 2,
})
return {"labels": labels, "datasets": datasets}
def render_html(
self,
report: dict[str, Any],
config: DashboardConfig,
) -> str:
"""Render the full HTML dashboard from aggregated report data."""
overall_health = report.get("overall_health", 0)
health_trend = report.get("health_trend", "stable")
# Category scores (with Korean labels)
cat_scores = report.get("category_scores", {})
category_labels = [
CATEGORY_KOREAN_LABELS.get(k, k) for k in cat_scores.keys()
]
category_values = list(cat_scores.values())
category_colors = [self._category_color(v) for v in category_values]
# Timeline
timeline = report.get("timeline", [])
timeline_dates = [e.get("date", "") for e in timeline]
timeline_scores = [e.get("health_score", 0) for e in timeline]
# Issues
top_issues = report.get("top_issues", [])
issues_chart = self.generate_issues_chart(top_issues)
# Wins
top_wins = report.get("top_wins", [])
# Competitor radar
has_competitor_data = False
radar_labels: list[str] = []
radar_datasets: list[dict] = []
raw_outputs = report.get("raw_outputs", [])
for output in raw_outputs:
if output.get("category") == "competitor":
has_competitor_data = True
comp_data = output.get("data", {})
if "comparison_matrix" in comp_data:
radar_result = self.generate_competitor_radar(
comp_data["comparison_matrix"]
)
radar_labels = radar_result["labels"]
radar_datasets = radar_result["datasets"]
break
context = {
"title": config.title,
"domain": config.domain or report.get("domain", ""),
"report_date": report.get("report_date", ""),
"audit_id": report.get("audit_id", ""),
"timestamp": report.get("timestamp", datetime.now().isoformat()),
"overall_health": overall_health,
"score_class": self._score_class(overall_health),
"health_trend": health_trend,
"trend_label": self._trend_label(health_trend),
"gauge_color": self._gauge_color(overall_health),
"category_labels": category_labels,
"category_values": category_values,
"category_colors": category_colors,
"timeline_dates": timeline_dates,
"timeline_scores": timeline_scores,
"issue_category_labels": issues_chart["labels"],
"issue_category_values": issues_chart["values"],
"top_issues": top_issues[:15],
"issues_count": len(top_issues),
"top_wins": top_wins[:10],
"wins_count": len(top_wins),
"timeline": timeline[:20],
"has_competitor_data": has_competitor_data,
"radar_labels": radar_labels,
"radar_datasets": radar_datasets,
}
return self.template.render(**context)
def save(self, html_content: str, output_path: str) -> None:
"""Save rendered HTML to a file."""
Path(output_path).write_text(html_content, encoding="utf-8")
logger.info(f"Dashboard saved to {output_path}")
def run(
self,
report_json: str,
output_path: str,
title: str = "SEO Reporting Dashboard",
) -> str:
"""Orchestrate dashboard generation from a report JSON file."""
# Load report data
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
config = DashboardConfig(
title=title,
domain=report.get("domain", ""),
date_range=report.get("report_date", ""),
)
# Render
html = self.render_html(report, config)
logger.info(f"Rendered HTML dashboard ({len(html):,} bytes)")
# Save
self.save(html, output_path)
return output_path
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="SEO Dashboard Generator - Interactive HTML dashboard with Chart.js",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
python dashboard_generator.py --report aggregated_report.json --output dashboard.html
python dashboard_generator.py --report aggregated_report.json --output dashboard.html --title "My Dashboard"
""",
)
parser.add_argument(
"--report",
required=True,
help="Path to aggregated report JSON file (from report_aggregator.py)",
)
parser.add_argument(
"--output",
required=True,
help="Output HTML file path",
)
parser.add_argument(
"--title",
type=str,
default="SEO Reporting Dashboard",
help="Dashboard title (default: 'SEO Reporting Dashboard')",
)
return parser.parse_args(argv)
def main() -> None:
args = parse_args()
generator = DashboardGenerator()
output = generator.run(
report_json=args.report,
output_path=args.output,
title=args.title,
)
logger.info(f"Dashboard generated: {output}")
if __name__ == "__main__":
main()