Add SEO skills 33-34 and fix bugs in skills 19-34
New skills: - Skill 33: Site migration planner with redirect mapping and monitoring - Skill 34: Reporting dashboard with HTML charts and Korean executive reports Bug fixes (Skill 34 - report_aggregator.py): - Add audit_type fallback for skill identification (was only using audit_id prefix) - Extract health scores from nested data dict (technical_score, onpage_score, etc.) - Support subdomain matching in domain filter (blog.ourdigital.org matches ourdigital.org) - Skip self-referencing DASH- aggregated reports Bug fixes (Skill 20 - naver_serp_analyzer.py): - Remove VIEW tab selectors (removed by Naver in 2026) - Add new section detectors: books (도서), shortform (숏폼), influencer (인플루언서) Improvements (Skill 34 - dashboard/executive report): - Add Korean category labels for Chart.js charts (기술 SEO, 온페이지, etc.) - Add Korean trend labels (개선 중 ↑, 안정 →, 하락 중 ↓) - Add English→Korean issue description translation layer (20 common patterns) Documentation improvements: - Add Korean triggers to 4 skill descriptions (19, 25, 28, 31) - Expand Skill 32 SKILL.md from 40→143 lines (was 6/10, added workflow, output format, limitations) - Add output format examples to Skills 27 and 28 SKILL.md - Add limitations sections to Skills 27 and 28 - Update README.md, CLAUDE.md, AGENTS.md for skills 33-34 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
"""
|
||||
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",
|
||||
"search_console": "Search Console",
|
||||
"ecommerce": "이커머스",
|
||||
"international": "국제 SEO",
|
||||
"ai_search": "AI 검색",
|
||||
"entity_seo": "엔티티 SEO",
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user