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

748 lines
25 KiB
Python

"""
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 = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - {{ domain }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-primary: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #212529;
--text-secondary: #6c757d;
--border: #dee2e6;
--accent: #0d6efd;
--success: #198754;
--warning: #ffc107;
--danger: #dc3545;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.header {
background: linear-gradient(135deg, #0d6efd 0%, #6610f2 100%);
color: white;
padding: 2rem;
text-align: center;
}
.header h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
.header .meta { opacity: 0.85; font-size: 0.9rem; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.grid-full {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--bg-card);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border: 1px solid var(--border);
}
.card h2 {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border);
}
.health-score {
text-align: center;
padding: 2rem;
}
.health-score .score {
font-size: 4rem;
font-weight: 700;
line-height: 1;
}
.health-score .label {
font-size: 1rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.health-score .trend {
font-size: 1.2rem;
margin-top: 0.5rem;
font-weight: 600;
}
.trend-improving { color: var(--success); }
.trend-stable { color: var(--warning); }
.trend-declining { color: var(--danger); }
.score-excellent { color: var(--success); }
.score-good { color: #20c997; }
.score-average { color: var(--warning); }
.score-poor { color: #fd7e14; }
.score-critical { color: var(--danger); }
.chart-container {
position: relative;
width: 100%;
height: 300px;
}
.issues-list { list-style: none; }
.issues-list li {
padding: 0.75rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.issues-list li:last-child { border-bottom: none; }
.severity-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.severity-critical { background: #f8d7da; color: #842029; }
.severity-high { background: #fff3cd; color: #664d03; }
.severity-medium { background: #cfe2ff; color: #084298; }
.severity-low { background: #d1e7dd; color: #0f5132; }
.timeline-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.timeline-table th {
text-align: left;
padding: 0.6rem;
border-bottom: 2px solid var(--border);
color: var(--text-secondary);
font-weight: 600;
}
.timeline-table td {
padding: 0.6rem;
border-bottom: 1px solid var(--border);
}
.footer {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
.header h1 { font-size: 1.4rem; }
.health-score .score { font-size: 3rem; }
}
</style>
</head>
<body>
<div class="header">
<h1>{{ title }}</h1>
<div class="meta">{{ domain }} | {{ report_date }} | Audit ID: {{ audit_id }}</div>
</div>
<div class="container">
<!-- Health Score & Category Overview -->
<div class="grid">
<div class="card health-score">
<div class="score {{ score_class }}">{{ overall_health }}</div>
<div class="label">Overall Health Score</div>
<div class="trend trend-{{ health_trend }}">{{ trend_label }}</div>
<div class="chart-container" style="height: 200px; margin-top: 1rem;">
<canvas id="gaugeChart"></canvas>
</div>
</div>
<div class="card">
<h2>Category Scores</h2>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
<!-- Traffic & Keywords -->
<div class="grid">
<div class="card">
<h2>Health Score Timeline</h2>
<div class="chart-container">
<canvas id="timelineChart"></canvas>
</div>
</div>
<div class="card">
<h2>Issue Distribution</h2>
<div class="chart-container">
<canvas id="issuesChart"></canvas>
</div>
</div>
</div>
<!-- Competitor Radar (if data available) -->
{% if has_competitor_data %}
<div class="grid">
<div class="card">
<h2>Competitive Comparison</h2>
<div class="chart-container">
<canvas id="radarChart"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Top Issues -->
<div class="grid-full">
<div class="card">
<h2>Top Issues ({{ issues_count }})</h2>
<ul class="issues-list">
{% for issue in top_issues %}
<li>
<span class="severity-badge severity-{{ issue.severity }}">{{ issue.severity }}</span>
<span>{{ issue.description }} <em style="color: var(--text-secondary);">({{ issue.category }})</em></span>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Top Wins -->
{% if top_wins %}
<div class="grid-full">
<div class="card">
<h2>Top Wins ({{ wins_count }})</h2>
<ul class="issues-list">
{% for win in top_wins %}
<li>
<span class="severity-badge severity-low">WIN</span>
<span>{{ win.description }} <em style="color: var(--text-secondary);">({{ win.category }})</em></span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Audit Timeline Table -->
<div class="grid-full">
<div class="card">
<h2>Audit History</h2>
<table class="timeline-table">
<thead>
<tr>
<th>Date</th>
<th>Skill</th>
<th>Category</th>
<th>Score</th>
<th>Issues</th>
</tr>
</thead>
<tbody>
{% for entry in timeline %}
<tr>
<td>{{ entry.date }}</td>
<td>{{ entry.skill }}</td>
<td>{{ entry.category }}</td>
<td>{{ entry.health_score }}</td>
<td>{{ entry.issues_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="footer">
Generated by SEO Reporting Dashboard (Skill 34) | {{ timestamp }}
</div>
<script>
// --- Gauge Chart ---
const gaugeCtx = document.getElementById('gaugeChart').getContext('2d');
new Chart(gaugeCtx, {
type: 'doughnut',
data: {
datasets: [{
data: [{{ overall_health }}, {{ 100 - overall_health }}],
backgroundColor: ['{{ gauge_color }}', '#e9ecef'],
borderWidth: 0,
circumference: 180,
rotation: 270,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%',
plugins: { legend: { display: false }, tooltip: { enabled: false } }
}
});
// --- Category Bar Chart ---
const catCtx = document.getElementById('categoryChart').getContext('2d');
new Chart(catCtx, {
type: 'bar',
data: {
labels: {{ category_labels | tojson }},
datasets: [{
label: 'Score',
data: {{ category_values | tojson }},
backgroundColor: {{ category_colors | tojson }},
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
scales: {
x: { min: 0, max: 100, grid: { display: false } },
y: { grid: { display: false } }
},
plugins: { legend: { display: false } }
}
});
// --- Timeline Line Chart ---
const timeCtx = document.getElementById('timelineChart').getContext('2d');
new Chart(timeCtx, {
type: 'line',
data: {
labels: {{ timeline_dates | tojson }},
datasets: [{
label: 'Health Score',
data: {{ timeline_scores | tojson }},
borderColor: '#0d6efd',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#0d6efd',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { min: 0, max: 100, grid: { color: '#f0f0f0' } },
x: { grid: { display: false } }
},
plugins: { legend: { display: false } }
}
});
// --- Issues Pie Chart ---
const issuesCtx = document.getElementById('issuesChart').getContext('2d');
new Chart(issuesCtx, {
type: 'pie',
data: {
labels: {{ issue_category_labels | tojson }},
datasets: [{
data: {{ issue_category_values | tojson }},
backgroundColor: [
'#dc3545', '#fd7e14', '#ffc107', '#198754',
'#0d6efd', '#6610f2', '#d63384', '#20c997',
'#0dcaf0', '#6c757d'
],
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { boxWidth: 12, padding: 8 } }
}
}
});
{% if has_competitor_data %}
// --- Competitor Radar Chart ---
const radarCtx = document.getElementById('radarChart').getContext('2d');
new Chart(radarCtx, {
type: 'radar',
data: {
labels: {{ radar_labels | tojson }},
datasets: {{ radar_datasets | tojson }}
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
r: { min: 0, max: 100, ticks: { stepSize: 20 } }
}
}
});
{% endif %}
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 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()