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:
2026-02-14 00:01:00 +09:00
parent dbfaa883cd
commit d2d0a2d460
37 changed files with 5462 additions and 56 deletions

View File

@@ -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()