748 lines
25 KiB
Python
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()
|