Spaces:
Running
Running
import json | |
from typing import Dict, Any, List | |
from datetime import datetime | |
import plotly.graph_objects as go | |
import plotly.express as px | |
from plotly.offline import plot | |
import plotly | |
import re | |
from utils import safe_pct | |
from benchmarks import BENCHMARKS, badge | |
class ReportGenerator: | |
def __init__(self): | |
self.report_template = self._get_report_template() | |
def _markdown_to_html(self, markdown_text: str) -> str: | |
"""Convert simple markdown to HTML""" | |
if not markdown_text: | |
return "" | |
html = markdown_text | |
# Convert headers | |
html = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html, flags=re.MULTILINE) | |
html = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html, flags=re.MULTILINE) | |
html = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html, flags=re.MULTILINE) | |
# Convert bold text | |
html = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html) | |
# Convert bullet points | |
html = re.sub(r'^- (.*?)$', r'<li>\1</li>', html, flags=re.MULTILINE) | |
html = re.sub(r'^β’ (.*?)$', r'<li>\1</li>', html, flags=re.MULTILINE) | |
# Wrap consecutive <li> tags in <ul> | |
html = re.sub(r'(<li>.*?</li>(?:\s*<li>.*?</li>)*)', r'<ul>\1</ul>', html, flags=re.DOTALL) | |
# Convert double line breaks to paragraphs | |
paragraphs = html.split('\n\n') | |
html_paragraphs = [] | |
for para in paragraphs: | |
para = para.strip() | |
if para: | |
# Don't wrap headers or lists in <p> tags | |
if not (para.startswith('<h') or para.startswith('<ul>') or para.startswith('<li>')): | |
para = f'<p>{para}</p>' | |
html_paragraphs.append(para) | |
html = '\n'.join(html_paragraphs) | |
# Convert remaining single line breaks to <br> tags within paragraphs | |
html = re.sub(r'(?<!>)\n(?!<)', '<br>', html) | |
# Clean up extra <br> tags around block elements | |
html = re.sub(r'<br>\s*(<h[1-6]>)', r'\1', html) | |
html = re.sub(r'(</h[1-6]>)\s*<br>', r'\1', html) | |
html = re.sub(r'<br>\s*(<ul>|<p>)', r'\1', html) | |
html = re.sub(r'(</ul>|</p>)\s*<br>', r'\1', html) | |
return html | |
def generate_html_report(self, url: str, technical_data: Dict[str, Any], | |
content_data: Dict[str, Any], competitor_data: List[Dict] = None, | |
keywords_data: Dict[str, Any] = None, backlinks_data: Dict[str, Any] = None, | |
llm_recommendations: Dict[str, Any] = None, include_charts: bool = True) -> str: | |
"""Generate complete HTML SEO report""" | |
# Generate charts | |
charts_html = "" | |
if include_charts: | |
charts_html = self._generate_charts(technical_data, content_data, competitor_data, keywords_data, backlinks_data) | |
# Generate executive summary with benchmarks | |
executive_summary = self._generate_executive_summary_with_badges(technical_data, content_data, keywords_data, backlinks_data) | |
# Generate technical SEO section | |
technical_section = self._generate_technical_section(technical_data) | |
# Generate content audit section | |
content_section = self._generate_content_section(content_data) | |
# Generate keywords section | |
keywords_section = self._generate_keywords_section(keywords_data) if keywords_data else "" | |
# Generate backlinks section | |
backlinks_section = self._generate_backlinks_section(backlinks_data) if backlinks_data else "" | |
# Generate LLM recommendations section | |
recommendations_section = self._generate_recommendations_section(llm_recommendations) if llm_recommendations else "" | |
# Generate competitor section | |
competitor_section = "" | |
if competitor_data: | |
competitor_section = self._generate_competitor_section(competitor_data, technical_data, content_data) | |
# Generate recommendations | |
recommendations = self._generate_recommendations(technical_data, content_data) | |
# Compile final report | |
report_html = self.report_template.format( | |
url=url, | |
generated_date=datetime.now().strftime("%B %d, %Y at %I:%M %p"), | |
charts=charts_html, | |
executive_summary=executive_summary, | |
technical_section=technical_section, | |
content_section=content_section, | |
keywords_section=keywords_section, | |
backlinks_section=backlinks_section, | |
competitor_section=competitor_section, | |
recommendations=recommendations, | |
llm_recommendations=recommendations_section | |
) | |
return report_html | |
def _generate_charts(self, technical_data: Dict[str, Any], content_data: Dict[str, Any], | |
competitor_data: List[Dict] = None, keywords_data: Dict[str, Any] = None, | |
backlinks_data: Dict[str, Any] = None) -> str: | |
"""Generate interactive charts using Plotly""" | |
charts_html = "" | |
# Performance Scores Chart | |
if not technical_data.get('error'): | |
mobile_scores = technical_data.get('mobile', {}) | |
desktop_scores = technical_data.get('desktop', {}) | |
performance_fig = go.Figure() | |
categories = ['Performance', 'SEO', 'Accessibility', 'Best Practices'] | |
mobile_values = [ | |
mobile_scores.get('performance_score', 0), | |
mobile_scores.get('seo_score', 0), | |
mobile_scores.get('accessibility_score', 0), | |
mobile_scores.get('best_practices_score', 0) | |
] | |
desktop_values = [ | |
desktop_scores.get('performance_score', 0), | |
desktop_scores.get('seo_score', 0), | |
desktop_scores.get('accessibility_score', 0), | |
desktop_scores.get('best_practices_score', 0) | |
] | |
performance_fig.add_trace(go.Bar( | |
name='Mobile', | |
x=categories, | |
y=mobile_values, | |
marker_color='#FF6B6B' | |
)) | |
performance_fig.add_trace(go.Bar( | |
name='Desktop', | |
x=categories, | |
y=desktop_values, | |
marker_color='#4ECDC4' | |
)) | |
performance_fig.update_layout( | |
title='PageSpeed Insights Scores', | |
xaxis_title='Categories', | |
yaxis_title='Score (0-100)', | |
barmode='group', | |
height=400, | |
showlegend=True | |
) | |
charts_html += f'<div class="chart-container">{plot(performance_fig, output_type="div", include_plotlyjs=False)}</div>' | |
# Core Web Vitals Chart | |
if not technical_data.get('error'): | |
cwv_data = technical_data.get('core_web_vitals', {}) | |
mobile_cwv = cwv_data.get('mobile', {}) | |
desktop_cwv = cwv_data.get('desktop', {}) | |
cwv_fig = go.Figure() | |
metrics = ['LCP (s)', 'CLS', 'INP (ms)', 'FCP (s)'] | |
mobile_cwv_values = [ | |
mobile_cwv.get('lcp', 0), | |
mobile_cwv.get('cls', 0), | |
mobile_cwv.get('inp', 0), | |
mobile_cwv.get('fcp', 0) | |
] | |
desktop_cwv_values = [ | |
desktop_cwv.get('lcp', 0), | |
desktop_cwv.get('cls', 0), | |
desktop_cwv.get('inp', 0), | |
desktop_cwv.get('fcp', 0) | |
] | |
cwv_fig.add_trace(go.Scatter( | |
name='Mobile', | |
x=metrics, | |
y=mobile_cwv_values, | |
mode='lines+markers', | |
line=dict(color='#FF6B6B', width=3), | |
marker=dict(size=8) | |
)) | |
cwv_fig.add_trace(go.Scatter( | |
name='Desktop', | |
x=metrics, | |
y=desktop_cwv_values, | |
mode='lines+markers', | |
line=dict(color='#4ECDC4', width=3), | |
marker=dict(size=8) | |
)) | |
cwv_fig.update_layout( | |
title='Core Web Vitals Performance', | |
xaxis_title='Metrics', | |
yaxis_title='Values', | |
height=400, | |
showlegend=True | |
) | |
charts_html += f'<div class="chart-container">{plot(cwv_fig, output_type="div", include_plotlyjs=False)}</div>' | |
# Metadata Completeness Chart | |
if not content_data.get('error'): | |
metadata = content_data.get('metadata_completeness', {}) | |
completeness_fig = go.Figure(data=[go.Pie( | |
labels=['Title Tags', 'Meta Descriptions', 'H1 Tags'], | |
values=[ | |
metadata.get('title_coverage', 0), | |
metadata.get('description_coverage', 0), | |
metadata.get('h1_coverage', 0) | |
], | |
hole=0.4, | |
marker_colors=['#FF6B6B', '#4ECDC4', '#45B7D1'] | |
)]) | |
completeness_fig.update_layout( | |
title='Metadata Completeness (%)', | |
height=400, | |
showlegend=True | |
) | |
charts_html += f'<div class="chart-container">{plot(completeness_fig, output_type="div", include_plotlyjs=False)}</div>' | |
# Content Freshness Chart | |
if not content_data.get('error'): | |
freshness = content_data.get('content_freshness', {}) | |
freshness_fig = go.Figure(data=[go.Pie( | |
labels=['Fresh (<6 months)', 'Moderate (6-18 months)', 'Stale (>18 months)', 'Unknown Date'], | |
values=[ | |
freshness.get('fresh_content', {}).get('count', 0), | |
freshness.get('moderate_content', {}).get('count', 0), | |
freshness.get('stale_content', {}).get('count', 0), | |
freshness.get('unknown_date', {}).get('count', 0) | |
], | |
marker_colors=['#2ECC71', '#F39C12', '#E74C3C', '#95A5A6'] | |
)]) | |
freshness_fig.update_layout( | |
title='Content Freshness Distribution', | |
height=400, | |
showlegend=True | |
) | |
charts_html += f'<div class="chart-container">{plot(freshness_fig, output_type="div", include_plotlyjs=False)}</div>' | |
return charts_html | |
def _generate_executive_summary(self, technical_data: Dict[str, Any], content_data: Dict[str, Any], | |
keywords_data: Dict[str, Any] = None, backlinks_data: Dict[str, Any] = None, | |
llm_recommendations: Dict[str, Any] = None) -> str: | |
"""Generate executive summary section""" | |
# Calculate overall health score | |
mobile_perf = technical_data.get('mobile', {}).get('performance_score', 0) | |
desktop_perf = technical_data.get('desktop', {}).get('performance_score', 0) | |
avg_performance = (mobile_perf + desktop_perf) / 2 | |
metadata_avg = 0 | |
if not content_data.get('error'): | |
metadata = content_data.get('metadata_completeness', {}) | |
metadata_avg = ( | |
metadata.get('title_coverage', 0) + | |
metadata.get('description_coverage', 0) + | |
metadata.get('h1_coverage', 0) | |
) / 3 | |
overall_score = (avg_performance + metadata_avg) / 2 | |
# Health status | |
if overall_score >= 80: | |
health_status = "Excellent" | |
health_color = "#2ECC71" | |
elif overall_score >= 60: | |
health_status = "Good" | |
health_color = "#F39C12" | |
elif overall_score >= 40: | |
health_status = "Fair" | |
health_color = "#FF6B6B" | |
else: | |
health_status = "Poor" | |
health_color = "#E74C3C" | |
# Quick wins | |
quick_wins = [] | |
if not content_data.get('error'): | |
metadata = content_data.get('metadata_completeness', {}) | |
if metadata.get('title_coverage', 0) < 90: | |
quick_wins.append(f"Complete missing title tags ({100 - metadata.get('title_coverage', 0):.1f}% of pages missing)") | |
if metadata.get('description_coverage', 0) < 90: | |
quick_wins.append(f"Add missing meta descriptions ({100 - metadata.get('description_coverage', 0):.1f}% of pages missing)") | |
if metadata.get('h1_coverage', 0) < 90: | |
quick_wins.append(f"Add missing H1 tags ({100 - metadata.get('h1_coverage', 0):.1f}% of pages missing)") | |
if mobile_perf < 70: | |
quick_wins.append(f"Improve mobile performance score (currently {mobile_perf:.1f}/100)") | |
quick_wins_html = "".join([f"<li>{win}</li>" for win in quick_wins[:5]]) | |
return f""" | |
<div class="summary-card"> | |
<div class="health-score"> | |
<h3>Overall SEO Health</h3> | |
<div class="score-circle" style="border-color: {health_color}"> | |
<span class="score-number" style="color: {health_color}">{overall_score:.0f}</span> | |
<span class="score-label">/ 100</span> | |
</div> | |
<p class="health-status" style="color: {health_color}">{health_status}</p> | |
</div> | |
<div class="key-metrics"> | |
<div class="metric"> | |
<h4>Performance Score</h4> | |
<p>Mobile: {mobile_perf:.1f}/100</p> | |
<p>Desktop: {desktop_perf:.1f}/100</p> | |
</div> | |
<div class="metric"> | |
<h4>Content Analysis</h4> | |
<p>Pages Analyzed: {content_data.get('pages_analyzed', 0)}</p> | |
<p>Metadata Completeness: {metadata_avg:.1f}%</p> | |
</div> | |
</div> | |
</div> | |
<div class="quick-wins"> | |
<h3>π― Quick Wins</h3> | |
<ul> | |
{quick_wins_html} | |
{'' if quick_wins else '<li>Great job! No immediate quick wins identified.</li>'} | |
</ul> | |
</div> | |
""" | |
def _generate_executive_summary_with_badges(self, technical_data: Dict[str, Any], | |
content_data: Dict[str, Any], | |
keywords_data: Dict[str, Any] = None, | |
backlinks_data: Dict[str, Any] = None) -> str: | |
"""Generate executive summary with benchmark badges""" | |
# Extract metrics for badges | |
mobile_score = technical_data.get('mobile', {}).get('performance_score', 0) | |
cwv = technical_data.get('core_web_vitals', {}).get('mobile', {}) | |
lcp_value = cwv.get('lcp', 0) | |
cls_value = cwv.get('cls', 0) | |
meta_complete_pct = content_data.get('meta_complete_pct', 0) | |
avg_words = content_data.get('avg_words', 0) | |
keywords_top10_pct = 0 | |
if keywords_data and not keywords_data.get('placeholder'): | |
dist = keywords_data.get('position_distribution', {}) | |
total = keywords_data.get('total_keywords', 0) | |
if total > 0: | |
keywords_top10_pct = (dist.get('top_10', 0) / total) * 100 | |
domain_rating = backlinks_data.get('domain_rating', 0) if backlinks_data else 0 | |
referring_domains = backlinks_data.get('total_ref_domains', 0) if backlinks_data else 0 | |
# Generate badges | |
badges_html = self._generate_benchmark_badges( | |
mobile_score, lcp_value, cls_value, meta_complete_pct, | |
avg_words, keywords_top10_pct, domain_rating, referring_domains | |
) | |
# Overall health score | |
overall_score = (mobile_score + meta_complete_pct) / 2 | |
if overall_score >= 80: | |
health_status = "Excellent" | |
health_color = "#2ECC71" | |
elif overall_score >= 60: | |
health_status = "Good" | |
health_color = "#F39C12" | |
elif overall_score >= 40: | |
health_status = "Fair" | |
health_color = "#FF6B6B" | |
else: | |
health_status = "Poor" | |
health_color = "#E74C3C" | |
return f""" | |
<div class="summary-card"> | |
<div class="health-score"> | |
<h3>Overall SEO Health</h3> | |
<div class="score-circle" style="border-color: {health_color}"> | |
<span class="score-number" style="color: {health_color}">{overall_score:.0f}</span> | |
<span class="score-label">/ 100</span> | |
</div> | |
<p class="health-status" style="color: {health_color}">{health_status}</p> | |
</div> | |
</div> | |
<h3>π Benchmark Performance</h3> | |
{badges_html} | |
""" | |
def _generate_benchmark_badges(self, mobile_score, lcp_value, cls_value, meta_complete_pct, | |
avg_words, keywords_top10_pct, domain_rating, referring_domains) -> str: | |
"""Generate benchmark badges for executive summary""" | |
badges = [ | |
badge(f"{mobile_score}", mobile_score >= BENCHMARKS['mobile_score_min']), | |
badge(f"{lcp_value:.1f}s", lcp_value <= BENCHMARKS['lcp_max'] if lcp_value > 0 else False), | |
badge(f"{cls_value:.3f}", cls_value <= BENCHMARKS['cls_max'] if cls_value >= 0 else False), | |
badge(f"{meta_complete_pct:.1f}%", meta_complete_pct >= BENCHMARKS['meta_complete_min']), | |
badge(f"{avg_words} words", BENCHMARKS['avg_words_min'] <= avg_words <= BENCHMARKS['avg_words_max'] if avg_words > 0 else False), | |
badge(f"{keywords_top10_pct:.1f}%", keywords_top10_pct >= BENCHMARKS['keywords_top10_min']), | |
badge(f"DR {domain_rating}", domain_rating >= BENCHMARKS['domain_rating_min']), | |
badge(f"{referring_domains} domains", referring_domains >= BENCHMARKS['referring_domains_min']) | |
] | |
badges_html = '<div class="benchmark-badges">' | |
labels = [ | |
"Mobile Performance", "LCP", "CLS", "Meta Completeness", | |
"Content Length", "Top 10 Keywords", "Domain Rating", "Referring Domains" | |
] | |
targets = [ | |
f"> {BENCHMARKS['mobile_score_min']}", | |
f"< {BENCHMARKS['lcp_max']}s", | |
f"< {BENCHMARKS['cls_max']}", | |
f"> {BENCHMARKS['meta_complete_min']}%", | |
f"{BENCHMARKS['avg_words_min']}-{BENCHMARKS['avg_words_max']}", | |
f"> {BENCHMARKS['keywords_top10_min']}%", | |
f"> {BENCHMARKS['domain_rating_min']}", | |
f"> {BENCHMARKS['referring_domains_min']}" | |
] | |
for i, (label, target, badge_data) in enumerate(zip(labels, targets, badges)): | |
status_class = 'pass' if badge_data['status'] == 'pass' else 'fail' | |
icon = 'β' if badge_data['status'] == 'pass' else 'β' | |
badges_html += f''' | |
<div class="benchmark-badge {status_class}"> | |
<div class="badge-icon">{icon}</div> | |
<div class="badge-content"> | |
<div class="badge-value">{badge_data['value']}</div> | |
<div class="badge-label">{label}</div> | |
<div class="badge-target">Target: {target}</div> | |
</div> | |
</div> | |
''' | |
badges_html += '</div>' | |
return badges_html | |
def _generate_technical_section(self, technical_data: Dict[str, Any]) -> str: | |
"""Generate technical SEO section""" | |
if technical_data.get('error'): | |
return f""" | |
<div class="error-message"> | |
<h3>β οΈ Technical SEO Analysis</h3> | |
<p>Unable to complete technical analysis: {technical_data.get('error')}</p> | |
</div> | |
""" | |
mobile = technical_data.get('mobile', {}) | |
desktop = technical_data.get('desktop', {}) | |
cwv = technical_data.get('core_web_vitals', {}) | |
opportunities = technical_data.get('opportunities', {}).get('opportunities', []) | |
# Core Web Vitals analysis | |
mobile_cwv = cwv.get('mobile', {}) | |
cwv_analysis = [] | |
lcp = mobile_cwv.get('lcp', 0) | |
if lcp > 2.5: | |
cwv_analysis.append(f"β οΈ LCP ({lcp:.2f}s) - Should be under 2.5s") | |
else: | |
cwv_analysis.append(f"β LCP ({lcp:.2f}s) - Good") | |
cls = mobile_cwv.get('cls', 0) | |
if cls > 0.1: | |
cwv_analysis.append(f"β οΈ CLS ({cls:.3f}) - Should be under 0.1") | |
else: | |
cwv_analysis.append(f"β CLS ({cls:.3f}) - Good") | |
# Opportunities list | |
opportunities_html = "" | |
for opp in opportunities[:5]: | |
opportunities_html += f""" | |
<div class="opportunity"> | |
<h4>{opp.get('title', 'Optimization Opportunity')}</h4> | |
<p>{opp.get('description', '')}</p> | |
<span class="savings">Potential savings: {opp.get('potential_savings', 0):.0f}ms</span> | |
</div> | |
""" | |
return f""" | |
<div class="technical-metrics"> | |
<div class="metric-row"> | |
<div class="metric-card"> | |
<h4>Mobile Performance</h4> | |
<div class="score">{mobile.get('performance_score', 0):.1f}/100</div> | |
</div> | |
<div class="metric-card"> | |
<h4>Desktop Performance</h4> | |
<div class="score">{desktop.get('performance_score', 0):.1f}/100</div> | |
</div> | |
<div class="metric-card"> | |
<h4>SEO Score</h4> | |
<div class="score">{mobile.get('seo_score', 0):.1f}/100</div> | |
</div> | |
<div class="metric-card"> | |
<h4>Accessibility</h4> | |
<div class="score">{mobile.get('accessibility_score', 0):.1f}/100</div> | |
</div> | |
</div> | |
</div> | |
<div class="cwv-analysis"> | |
<h3>Core Web Vitals Analysis</h3> | |
<ul> | |
{"".join([f"<li>{analysis}</li>" for analysis in cwv_analysis])} | |
</ul> | |
</div> | |
<div class="optimization-opportunities"> | |
<h3>π§ Optimization Opportunities</h3> | |
{opportunities_html if opportunities_html else '<p>No major optimization opportunities identified.</p>'} | |
</div> | |
""" | |
def _generate_content_section(self, content_data: Dict[str, Any]) -> str: | |
"""Generate content audit section""" | |
if content_data.get('error'): | |
return f""" | |
<div class="error-message"> | |
<h3>β οΈ Content Audit</h3> | |
<p>Unable to complete content analysis: {content_data.get('error')}</p> | |
</div> | |
""" | |
metadata = content_data.get('metadata_completeness', {}) | |
content_metrics = content_data.get('content_metrics', {}) | |
freshness = content_data.get('content_freshness', {}) | |
return f""" | |
<div class="content-overview"> | |
<div class="metric-row"> | |
<div class="metric-card"> | |
<h4>Pages Discovered</h4> | |
<div class="score">{content_data.get('total_pages_discovered', 0)}</div> | |
</div> | |
<div class="metric-card"> | |
<h4>Pages Analyzed</h4> | |
<div class="score">{content_data.get('pages_analyzed', 0)}</div> | |
</div> | |
<div class="metric-card"> | |
<h4>Avg. Word Count</h4> | |
<div class="score">{content_metrics.get('avg_word_count', 0):.0f}</div> | |
</div> | |
<div class="metric-card"> | |
<h4>CTA Coverage</h4> | |
<div class="score">{content_metrics.get('cta_coverage', 0):.1f}%</div> | |
</div> | |
</div> | |
</div> | |
<div class="metadata-analysis"> | |
<h3>π Metadata Completeness</h3> | |
<div class="metadata-stats"> | |
<div class="stat"> | |
<span class="label">Title Tags:</span> | |
<span class="value">{metadata.get('title_coverage', 0):.1f}% complete</span> | |
<span class="benchmark">(Target: 90%+)</span> | |
</div> | |
<div class="stat"> | |
<span class="label">Meta Descriptions:</span> | |
<span class="value">{metadata.get('description_coverage', 0):.1f}% complete</span> | |
<span class="benchmark">(Target: 90%+)</span> | |
</div> | |
<div class="stat"> | |
<span class="label">H1 Tags:</span> | |
<span class="value">{metadata.get('h1_coverage', 0):.1f}% complete</span> | |
<span class="benchmark">(Target: 90%+)</span> | |
</div> | |
</div> | |
</div> | |
<div class="content-quality"> | |
<h3>π Content Quality Metrics</h3> | |
<div class="quality-stats"> | |
<div class="stat"> | |
<span class="label">Average Word Count:</span> | |
<span class="value">{content_metrics.get('avg_word_count', 0):.0f} words</span> | |
<span class="benchmark">(Recommended: 800-1200)</span> | |
</div> | |
<div class="stat"> | |
<span class="label">Call-to-Action Coverage:</span> | |
<span class="value">{content_metrics.get('cta_coverage', 0):.1f}% of pages</span> | |
<span class="benchmark">(Target: 80%+)</span> | |
</div> | |
</div> | |
</div> | |
<div class="content-freshness"> | |
<h3>ποΈ Content Freshness</h3> | |
<div class="freshness-stats"> | |
<div class="stat"> | |
<span class="label">Fresh Content (<6 months):</span> | |
<span class="value">{freshness.get('fresh_content', {}).get('percentage', 0):.1f}%</span> | |
</div> | |
<div class="stat"> | |
<span class="label">Moderate Age (6-18 months):</span> | |
<span class="value">{freshness.get('moderate_content', {}).get('percentage', 0):.1f}%</span> | |
</div> | |
<div class="stat"> | |
<span class="label">Stale Content (>18 months):</span> | |
<span class="value">{freshness.get('stale_content', {}).get('percentage', 0):.1f}%</span> | |
</div> | |
</div> | |
</div> | |
""" | |
def _generate_competitor_section(self, competitor_data: List[Dict], | |
primary_technical: Dict[str, Any], | |
primary_content: Dict[str, Any]) -> str: | |
"""Generate competitor comparison section""" | |
if not competitor_data: | |
return "" | |
comparison_html = """ | |
<div class="competitor-comparison"> | |
<h3>π Competitor Benchmarking</h3> | |
<table class="comparison-table"> | |
<thead> | |
<tr> | |
<th>Domain</th> | |
<th>Mobile Perf.</th> | |
<th>Desktop Perf.</th> | |
<th>SEO Score</th> | |
<th>Content Pages</th> | |
</tr> | |
</thead> | |
<tbody> | |
""" | |
# Add primary site | |
primary_mobile = primary_technical.get('mobile', {}).get('performance_score', 0) | |
primary_desktop = primary_technical.get('desktop', {}).get('performance_score', 0) | |
primary_seo = primary_technical.get('mobile', {}).get('seo_score', 0) | |
primary_pages = primary_content.get('pages_analyzed', 0) | |
comparison_html += f""" | |
<tr class="primary-site"> | |
<td><strong>Your Site</strong></td> | |
<td>{primary_mobile:.1f}</td> | |
<td>{primary_desktop:.1f}</td> | |
<td>{primary_seo:.1f}</td> | |
<td>{primary_pages}</td> | |
</tr> | |
""" | |
# Add competitors | |
for comp in competitor_data: | |
comp_technical = comp.get('technical', {}) | |
comp_content = comp.get('content', {}) | |
comp_mobile = comp_technical.get('mobile', {}).get('performance_score', 0) | |
comp_desktop = comp_technical.get('desktop', {}).get('performance_score', 0) | |
comp_seo = comp_technical.get('mobile', {}).get('seo_score', 0) | |
comp_pages = comp_content.get('pages_analyzed', 0) | |
domain = comp.get('url', '').replace('https://', '').replace('http://', '') | |
comparison_html += f""" | |
<tr> | |
<td>{domain}</td> | |
<td>{comp_mobile:.1f}</td> | |
<td>{comp_desktop:.1f}</td> | |
<td>{comp_seo:.1f}</td> | |
<td>{comp_pages}</td> | |
</tr> | |
""" | |
comparison_html += """ | |
</tbody> | |
</table> | |
</div> | |
""" | |
return comparison_html | |
def _generate_recommendations(self, technical_data: Dict[str, Any], content_data: Dict[str, Any]) -> str: | |
"""Generate prioritized recommendations""" | |
recommendations = [] | |
# Technical recommendations | |
if not technical_data.get('error'): | |
mobile = technical_data.get('mobile', {}) | |
if mobile.get('performance_score', 0) < 70: | |
recommendations.append({ | |
'priority': 'High', | |
'category': 'Technical SEO', | |
'title': 'Improve Mobile Performance', | |
'description': f'Mobile performance score is {mobile.get("performance_score", 0):.1f}/100. Focus on Core Web Vitals optimization.', | |
'timeline': '2-4 weeks' | |
}) | |
# Content recommendations | |
if not content_data.get('error'): | |
metadata = content_data.get('metadata_completeness', {}) | |
if metadata.get('title_coverage', 0) < 90: | |
recommendations.append({ | |
'priority': 'High', | |
'category': 'Content', | |
'title': 'Complete Missing Title Tags', | |
'description': f'{100 - metadata.get("title_coverage", 0):.1f}% of pages are missing title tags. This directly impacts search visibility.', | |
'timeline': '1-2 weeks' | |
}) | |
if metadata.get('description_coverage', 0) < 90: | |
recommendations.append({ | |
'priority': 'Medium', | |
'category': 'Content', | |
'title': 'Add Missing Meta Descriptions', | |
'description': f'{100 - metadata.get("description_coverage", 0):.1f}% of pages are missing meta descriptions. Improve click-through rates from search results.', | |
'timeline': '2-3 weeks' | |
}) | |
content_metrics = content_data.get('content_metrics', {}) | |
if content_metrics.get('avg_word_count', 0) < 800: | |
recommendations.append({ | |
'priority': 'Medium', | |
'category': 'Content', | |
'title': 'Increase Content Depth', | |
'description': f'Average word count is {content_metrics.get("avg_word_count", 0):.0f} words. Aim for 800-1200 words per page for better rankings.', | |
'timeline': '4-6 weeks' | |
}) | |
# Sort by priority | |
priority_order = {'High': 0, 'Medium': 1, 'Low': 2} | |
recommendations.sort(key=lambda x: priority_order.get(x['priority'], 2)) | |
recommendations_html = "" | |
for i, rec in enumerate(recommendations[:8], 1): | |
priority_color = { | |
'High': '#E74C3C', | |
'Medium': '#F39C12', | |
'Low': '#2ECC71' | |
}.get(rec['priority'], '#95A5A6') | |
recommendations_html += f""" | |
<div class="recommendation"> | |
<div class="rec-header"> | |
<span class="rec-number">{i}</span> | |
<span class="rec-priority" style="background-color: {priority_color}">{rec['priority']}</span> | |
<span class="rec-category">{rec['category']}</span> | |
</div> | |
<h4>{rec['title']}</h4> | |
<p>{rec['description']}</p> | |
<div class="rec-timeline">Timeline: {rec['timeline']}</div> | |
</div> | |
""" | |
return f""" | |
<div class="recommendations-section"> | |
<h3>π― Prioritized Recommendations</h3> | |
<div class="recommendations-list"> | |
{recommendations_html if recommendations_html else '<p>Great job! No immediate recommendations identified.</p>'} | |
</div> | |
</div> | |
""" | |
def _generate_keywords_section(self, keywords_data: Dict[str, Any]) -> str: | |
"""Generate keywords analysis section""" | |
if keywords_data.get('placeholder'): | |
return f""" | |
<div class="placeholder-section"> | |
<h3>π Keyword Rankings</h3> | |
<div class="placeholder-content"> | |
<p><strong>No keyword data available.</strong></p> | |
<p>{keywords_data.get('message', 'Connect Google Search Console or SERP API to unlock keyword insights.')}</p> | |
</div> | |
</div> | |
""" | |
total = keywords_data.get('total_keywords', 0) | |
pos_dist = keywords_data.get('position_distribution', {}) | |
best_keywords = keywords_data.get('best_keywords', []) | |
opportunity_keywords = keywords_data.get('opportunity_keywords', []) | |
worst_keywords = keywords_data.get('worst_keywords', {}) | |
# Create position distribution chart | |
pos_chart = "" | |
if pos_dist: | |
import plotly.graph_objects as go | |
from plotly.offline import plot | |
labels = ['Top 3', 'Top 10', 'Top 50', 'Beyond 50'] | |
values = [ | |
pos_dist.get('top_3', 0), | |
pos_dist.get('top_10', 0) - pos_dist.get('top_3', 0), | |
pos_dist.get('top_50', 0) - pos_dist.get('top_10', 0), | |
pos_dist.get('beyond_50', 0) | |
] | |
fig = go.Figure(data=[go.Pie(labels=labels, values=values, hole=0.4)]) | |
fig.update_layout(title="Keyword Position Distribution", height=400) | |
pos_chart = plot(fig, include_plotlyjs=False, output_type='div') | |
best_keywords_html = "" | |
if best_keywords: | |
best_keywords_html = "<h4>π Top Performing Keywords</h4><table class='data-table'><tr><th>Keyword</th><th>Position</th><th>Clicks</th><th>Impressions</th></tr>" | |
for kw in best_keywords[:10]: | |
best_keywords_html += f""" | |
<tr> | |
<td>{kw.get('keyword', '')}</td> | |
<td>{kw.get('position', 0)}</td> | |
<td>{kw.get('clicks', 0)}</td> | |
<td>{kw.get('impressions', 0)}</td> | |
</tr> | |
""" | |
best_keywords_html += "</table>" | |
opportunity_html = "" | |
if opportunity_keywords: | |
opportunity_html = "<h4>π Opportunity Keywords</h4><table class='data-table'><tr><th>Keyword</th><th>Position</th><th>Impressions</th><th>CTR</th></tr>" | |
for kw in opportunity_keywords[:10]: | |
opportunity_html += f""" | |
<tr> | |
<td>{kw.get('keyword', '')}</td> | |
<td>{kw.get('position', 0)}</td> | |
<td>{kw.get('impressions', 0)}</td> | |
<td>{kw.get('ctr', 0)}%</td> | |
</tr> | |
""" | |
opportunity_html += "</table>" | |
# Worst performing keywords | |
worst_keywords_html = "" | |
if worst_keywords.get('by_ctr') or worst_keywords.get('by_position'): | |
worst_keywords_html = "<h4>β οΈ Worst Performing Keywords</h4>" | |
if worst_keywords.get('by_ctr'): | |
worst_keywords_html += "<h5>By CTR (Low Click-Through Rate)</h5>" | |
worst_keywords_html += "<table class='data-table'><tr><th>Keyword</th><th>Position</th><th>Impressions</th><th>CTR</th></tr>" | |
for kw in worst_keywords['by_ctr'][:10]: | |
worst_keywords_html += f""" | |
<tr> | |
<td>{kw.get('keyword', '')}</td> | |
<td>{kw.get('rank', 0)}</td> | |
<td>{kw.get('impressions', 0)}</td> | |
<td>{kw.get('estimated_ctr', 0):.2f}%</td> | |
</tr> | |
""" | |
worst_keywords_html += "</table>" | |
if worst_keywords.get('by_position'): | |
worst_keywords_html += "<h5>By Position (Poor Rankings)</h5>" | |
worst_keywords_html += "<table class='data-table'><tr><th>Keyword</th><th>Position</th><th>Impressions</th></tr>" | |
for kw in worst_keywords['by_position'][:10]: | |
worst_keywords_html += f""" | |
<tr> | |
<td>{kw.get('keyword', '')}</td> | |
<td>{kw.get('rank', 0)}</td> | |
<td>{kw.get('impressions', 0)}</td> | |
</tr> | |
""" | |
worst_keywords_html += "</table>" | |
return f""" | |
<div class="card"> | |
<h3>π Keyword Rankings Analysis</h3> | |
<div class="metrics-grid"> | |
<div class="metric-card"> | |
<div class="metric-value">{total}</div> | |
<div class="metric-label">Total Keywords</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{pos_dist.get('top_10', 0)}</div> | |
<div class="metric-label">Top 10 Rankings</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{len(opportunity_keywords)}</div> | |
<div class="metric-label">Opportunities</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{keywords_data.get('data_source', 'Unknown')}</div> | |
<div class="metric-label">Data Source</div> | |
</div> | |
</div> | |
{pos_chart} | |
{best_keywords_html} | |
{worst_keywords_html} | |
{opportunity_html} | |
</div> | |
""" | |
def _generate_backlinks_section(self, backlinks_data: Dict[str, Any]) -> str: | |
"""Generate backlinks analysis section""" | |
if backlinks_data.get('placeholder'): | |
return f""" | |
<div class="placeholder-section"> | |
<h3>π Backlink Profile</h3> | |
<div class="placeholder-content"> | |
<p><strong>No backlink data available.</strong></p> | |
<p>{backlinks_data.get('message', 'Add RapidAPI key to unlock comprehensive backlink insights.')}</p> | |
</div> | |
</div> | |
""" | |
total_backlinks = backlinks_data.get('total_backlinks', 0) | |
total_ref_domains = backlinks_data.get('total_ref_domains', 0) | |
domain_rating = backlinks_data.get('domain_rating', 0) | |
monthly_changes = backlinks_data.get('monthly_changes', {}) | |
referring_domains = backlinks_data.get('referring_domains', []) | |
anchor_distribution = backlinks_data.get('anchor_distribution', []) | |
new_backlinks = backlinks_data.get('new_backlinks_30d', 0) | |
lost_backlinks = backlinks_data.get('lost_backlinks_30d') | |
data_source = backlinks_data.get('data_source', 'Unknown') | |
# Create anchor text distribution chart | |
anchor_chart = "" | |
if anchor_distribution: | |
import plotly.graph_objects as go | |
from plotly.offline import plot | |
anchors = [a.get('anchor_text', '')[:30] for a in anchor_distribution[:10]] | |
counts = [a.get('backlinks', 0) for a in anchor_distribution[:10]] | |
fig = go.Figure(data=[go.Bar(x=anchors, y=counts)]) | |
fig.update_layout(title="Top Anchor Text Distribution", height=400, xaxis={'tickangle': 45}) | |
anchor_chart = plot(fig, include_plotlyjs=False, output_type='div') | |
ref_domains_html = "" | |
if referring_domains: | |
ref_domains_html = "<h4>π’ Top Referring Domains</h4><table class='data-table'><tr><th>Domain</th><th>Domain Rating</th><th>Backlinks</th><th>First Seen</th></tr>" | |
for rd in referring_domains[:10]: | |
ref_domains_html += f""" | |
<tr> | |
<td>{rd.get('domain', '')}</td> | |
<td>{rd.get('domain_rating', 0)}</td> | |
<td>{rd.get('backlinks', 0)}</td> | |
<td>{rd.get('first_seen', 'N/A')}</td> | |
</tr> | |
""" | |
ref_domains_html += "</table>" | |
lost_display = "N/A (future work)" if lost_backlinks is None else str(lost_backlinks) | |
return f""" | |
<div class="card"> | |
<h3>π Backlink Profile Analysis</h3> | |
<p class="data-source-label">Source: {data_source}</p> | |
<div class="metrics-grid"> | |
<div class="metric-card"> | |
<div class="metric-value">{total_backlinks:,}</div> | |
<div class="metric-label">Total Backlinks</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{total_ref_domains:,}</div> | |
<div class="metric-label">Referring Domains</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{domain_rating}</div> | |
<div class="metric-label">Domain Rating</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{new_backlinks}</div> | |
<div class="metric-label">New Links (30d)</div> | |
</div> | |
<div class="metric-card"> | |
<div class="metric-value">{lost_display}</div> | |
<div class="metric-label">Lost Links (30d)</div> | |
</div> | |
</div> | |
{anchor_chart} | |
{ref_domains_html} | |
</div> | |
""" | |
def _generate_recommendations_section(self, llm_recommendations: Dict[str, Any]) -> str: | |
"""Generate LLM-powered recommendations section with markdown rendering""" | |
if not llm_recommendations: | |
return "" | |
recommendations_markdown = llm_recommendations.get('recommendations_markdown', '') | |
executive_insights = llm_recommendations.get('executive_insights', []) | |
priority_actions = llm_recommendations.get('priority_actions', []) | |
# Skip executive insights and priority actions - show only markdown | |
insights_html = "" | |
priority_html = "" | |
# Convert markdown recommendations to HTML | |
recommendations_html = "" | |
if recommendations_markdown: | |
recommendations_html = f""" | |
<div class='llm-recommendations'> | |
<h4>π€ AI-Generated Recommendations</h4> | |
<div class="markdown-content"> | |
{self._markdown_to_html(recommendations_markdown)} | |
</div> | |
</div> | |
""" | |
return f""" | |
<div class="card"> | |
<h3>π§ Smart Recommendations</h3> | |
<p class="data-source">Generated by {llm_recommendations.get('data_source', 'AI Analysis')}</p> | |
{insights_html} | |
{priority_html} | |
{recommendations_html} | |
</div> | |
""" | |
def _get_report_template(self) -> str: | |
"""Get the HTML template for the report""" | |
return """ | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>SEO Report - {url}</title> | |
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script> | |
<style> | |
* {{ | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
}} | |
body {{ | |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
line-height: 1.6; | |
color: #333; | |
background-color: #f8f9fa; | |
}} | |
.report-container {{ | |
max-width: 1200px; | |
margin: 0 auto; | |
padding: 20px; | |
}} | |
.report-header {{ | |
background: #f8f9fa; | |
color: #333; | |
border: 2px solid #e9ecef; | |
padding: 40px; | |
border-radius: 10px; | |
margin-bottom: 30px; | |
text-align: center; | |
}} | |
.report-header h1 {{ | |
font-size: 2.5rem; | |
margin-bottom: 10px; | |
}} | |
.report-header p {{ | |
font-size: 1.1rem; | |
opacity: 0.9; | |
}} | |
.section {{ | |
background: white; | |
margin-bottom: 30px; | |
padding: 30px; | |
border-radius: 10px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
}} | |
.section h2 {{ | |
color: #2c3e50; | |
margin-bottom: 20px; | |
font-size: 1.8rem; | |
border-bottom: 3px solid #3498db; | |
padding-bottom: 10px; | |
}} | |
.summary-card {{ | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 30px; | |
padding: 20px; | |
background: #f8f9fa; | |
border: 2px solid #28a745; | |
border-radius: 10px; | |
color: #333; | |
}} | |
.health-score {{ | |
text-align: center; | |
}} | |
.score-circle {{ | |
width: 120px; | |
height: 120px; | |
border: 6px solid; | |
border-radius: 50%; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
margin: 10px auto; | |
}} | |
.score-number {{ | |
font-size: 2rem; | |
font-weight: bold; | |
}} | |
.score-label {{ | |
font-size: 0.9rem; | |
opacity: 0.8; | |
}} | |
.health-status {{ | |
font-size: 1.2rem; | |
font-weight: bold; | |
margin-top: 10px; | |
}} | |
.key-metrics {{ | |
display: flex; | |
gap: 30px; | |
}} | |
.metric {{ | |
text-align: center; | |
}} | |
.metric h4 {{ | |
margin-bottom: 10px; | |
font-size: 1rem; | |
opacity: 0.9; | |
}} | |
.metric p {{ | |
font-size: 1.1rem; | |
margin-bottom: 5px; | |
}} | |
.quick-wins {{ | |
background: #fff3cd; | |
border: 1px solid #ffeeba; | |
border-radius: 8px; | |
padding: 20px; | |
}} | |
.quick-wins h3 {{ | |
color: #856404; | |
margin-bottom: 15px; | |
}} | |
.quick-wins ul {{ | |
list-style-type: none; | |
}} | |
.quick-wins li {{ | |
color: #856404; | |
margin-bottom: 8px; | |
position: relative; | |
padding-left: 20px; | |
}} | |
.quick-wins li:before {{ | |
content: "β"; | |
position: absolute; | |
left: 0; | |
color: #ffc107; | |
font-weight: bold; | |
}} | |
.metric-row {{ | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
gap: 20px; | |
margin-bottom: 30px; | |
}} | |
.metric-card {{ | |
background: #fff; | |
border: 2px solid #6c757d; | |
color: #333; | |
padding: 20px; | |
border-radius: 10px; | |
text-align: center; | |
}} | |
.metric-card h4 {{ | |
font-size: 0.9rem; | |
margin-bottom: 10px; | |
opacity: 0.9; | |
}} | |
.metric-card .score {{ | |
font-size: 2rem; | |
font-weight: bold; | |
}} | |
.chart-container {{ | |
margin: 30px 0; | |
background: white; | |
border-radius: 10px; | |
padding: 20px; | |
box-shadow: 0 2px 5px rgba(0,0,0,0.1); | |
}} | |
.cwv-analysis ul, .metadata-stats, .quality-stats, .freshness-stats {{ | |
list-style: none; | |
}} | |
.stat {{ | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 10px 0; | |
border-bottom: 1px solid #eee; | |
}} | |
.stat:last-child {{ | |
border-bottom: none; | |
}} | |
.stat .label {{ | |
font-weight: 600; | |
color: #2c3e50; | |
}} | |
.stat .value {{ | |
font-weight: bold; | |
color: #3498db; | |
}} | |
.stat .benchmark {{ | |
font-size: 0.85rem; | |
color: #7f8c8d; | |
}} | |
.opportunity {{ | |
background: #f8f9fa; | |
border-left: 4px solid #ff6b6b; | |
padding: 15px; | |
margin-bottom: 15px; | |
border-radius: 5px; | |
}} | |
.opportunity h4 {{ | |
color: #2c3e50; | |
margin-bottom: 8px; | |
}} | |
.savings {{ | |
display: inline-block; | |
background: #ff6b6b; | |
color: white; | |
padding: 4px 8px; | |
border-radius: 4px; | |
font-size: 0.8rem; | |
margin-top: 8px; | |
}} | |
.comparison-table {{ | |
width: 100%; | |
border-collapse: collapse; | |
margin-top: 20px; | |
}} | |
.comparison-table th, | |
.comparison-table td {{ | |
padding: 12px; | |
text-align: left; | |
border-bottom: 1px solid #ddd; | |
}} | |
.comparison-table th {{ | |
background: #f8f9fa; | |
font-weight: bold; | |
color: #2c3e50; | |
}} | |
.primary-site {{ | |
background: #e8f5e8; | |
font-weight: bold; | |
}} | |
.placeholder-sections {{ | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
gap: 20px; | |
}} | |
.placeholder-section {{ | |
border: 2px dashed #ddd; | |
border-radius: 10px; | |
padding: 20px; | |
text-align: center; | |
background: #fafafa; | |
}} | |
.placeholder-section h3 {{ | |
color: #7f8c8d; | |
margin-bottom: 15px; | |
}} | |
.placeholder-content p {{ | |
color: #7f8c8d; | |
font-style: italic; | |
margin-bottom: 15px; | |
}} | |
.placeholder-content ul {{ | |
list-style: none; | |
color: #95a5a6; | |
}} | |
.placeholder-content li {{ | |
margin-bottom: 8px; | |
}} | |
.recommendations-section {{ | |
background: #f8f9fa; | |
border: 2px solid #007bff; | |
color: #333; | |
border-radius: 10px; | |
padding: 30px; | |
}} | |
.recommendations-section h3 {{ | |
margin-bottom: 25px; | |
font-size: 1.8rem; | |
}} | |
.recommendation {{ | |
background: white; | |
color: #333; | |
border-radius: 8px; | |
padding: 20px; | |
margin-bottom: 20px; | |
}} | |
.rec-header {{ | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
margin-bottom: 10px; | |
}} | |
.rec-number {{ | |
background: #3498db; | |
color: white; | |
width: 30px; | |
height: 30px; | |
border-radius: 50%; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
font-weight: bold; | |
}} | |
.rec-priority {{ | |
color: white; | |
padding: 4px 8px; | |
border-radius: 4px; | |
font-size: 0.8rem; | |
font-weight: bold; | |
}} | |
.rec-category {{ | |
background: #ecf0f1; | |
color: #2c3e50; | |
padding: 4px 8px; | |
border-radius: 4px; | |
font-size: 0.8rem; | |
}} | |
.rec-timeline {{ | |
color: #7f8c8d; | |
font-size: 0.9rem; | |
margin-top: 10px; | |
font-weight: bold; | |
}} | |
.error-message {{ | |
background: #f8d7da; | |
border: 1px solid #f5c6cb; | |
color: #721c24; | |
padding: 20px; | |
border-radius: 8px; | |
text-align: center; | |
}} | |
.markdown-content {{ | |
line-height: 1.6; | |
color: #2c3e50; | |
}} | |
.markdown-content h1 {{ | |
color: #2c3e50; | |
border-bottom: 2px solid #3498db; | |
padding-bottom: 10px; | |
margin-top: 30px; | |
margin-bottom: 20px; | |
}} | |
.markdown-content h2 {{ | |
color: #34495e; | |
margin-top: 25px; | |
margin-bottom: 15px; | |
font-size: 1.3em; | |
}} | |
.markdown-content h3 {{ | |
color: #34495e; | |
margin-top: 20px; | |
margin-bottom: 10px; | |
font-size: 1.1em; | |
}} | |
.markdown-content strong {{ | |
color: #2c3e50; | |
font-weight: 600; | |
}} | |
.markdown-content ul {{ | |
margin: 15px 0; | |
padding-left: 20px; | |
}} | |
.markdown-content li {{ | |
margin-bottom: 8px; | |
line-height: 1.5; | |
}} | |
.llm-recommendations {{ | |
background: #f8f9fa; | |
border-left: 4px solid #3498db; | |
padding: 20px; | |
margin: 20px 0; | |
border-radius: 0 8px 8px 0; | |
}} | |
@media (max-width: 768px) {{ | |
.report-container {{ | |
padding: 10px; | |
}} | |
.section {{ | |
padding: 20px; | |
}} | |
.summary-card {{ | |
flex-direction: column; | |
text-align: center; | |
gap: 20px; | |
}} | |
.key-metrics {{ | |
flex-direction: column; | |
gap: 15px; | |
}} | |
.metric-row {{ | |
grid-template-columns: 1fr; | |
}} | |
}} | |
/* Benchmark badges */ | |
.benchmark-badges {{ | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
gap: 15px; | |
margin-bottom: 30px; | |
padding: 20px; | |
background: #f8f9fa; | |
border-radius: 10px; | |
border: 2px solid #e9ecef; | |
}} | |
.benchmark-badge {{ | |
display: flex; | |
align-items: center; | |
background: white; | |
padding: 15px; | |
border-radius: 8px; | |
border: 2px solid; | |
}} | |
.benchmark-badge.pass {{ | |
border-color: #28a745; | |
background: #f8fff8; | |
}} | |
.benchmark-badge.fail {{ | |
border-color: #dc3545; | |
background: #fff8f8; | |
}} | |
.badge-icon {{ | |
font-size: 1.2rem; | |
margin-right: 12px; | |
font-weight: bold; | |
}} | |
.benchmark-badge.pass .badge-icon {{ | |
color: #28a745; | |
}} | |
.benchmark-badge.fail .badge-icon {{ | |
color: #dc3545; | |
}} | |
.badge-content {{ | |
flex: 1; | |
}} | |
.badge-value {{ | |
font-weight: bold; | |
font-size: 1rem; | |
margin-bottom: 2px; | |
}} | |
.badge-label {{ | |
font-size: 0.85rem; | |
color: #666; | |
margin-bottom: 2px; | |
}} | |
.badge-target {{ | |
font-size: 0.75rem; | |
color: #888; | |
}} | |
/* Data source labels */ | |
.data-source-label {{ | |
font-size: 0.9rem; | |
color: #6c757d; | |
font-style: italic; | |
margin-bottom: 15px; | |
}} | |
/* Benchmark target labels */ | |
.benchmark-target {{ | |
font-size: 0.8rem; | |
color: #6c757d; | |
margin-bottom: 10px; | |
font-style: italic; | |
}} | |
/* Stale pages section */ | |
.stale-pages-section {{ | |
margin: 20px 0; | |
padding: 20px; | |
background: #fff3cd; | |
border: 1px solid #ffeeba; | |
border-radius: 8px; | |
}} | |
.stale-pages-list {{ | |
max-height: 300px; | |
overflow-y: auto; | |
}} | |
.stale-page-item {{ | |
padding: 8px 0; | |
border-bottom: 1px solid #f0f0f0; | |
font-size: 0.9rem; | |
}} | |
.stale-page-item:last-child {{ | |
border-bottom: none; | |
}} | |
.stale-page-item .url {{ | |
color: #007bff; | |
margin-right: 10px; | |
}} | |
.stale-page-item .date {{ | |
color: #6c757d; | |
font-size: 0.8rem; | |
}} | |
.more-pages {{ | |
padding: 10px; | |
text-align: center; | |
font-style: italic; | |
color: #6c757d; | |
}} | |
/* hreflang section */ | |
.hreflang-section {{ | |
margin: 20px 0; | |
padding: 20px; | |
background: #d1ecf1; | |
border: 1px solid #bee5eb; | |
border-radius: 8px; | |
}} | |
.hreflang-summary {{ | |
font-weight: bold; | |
margin-bottom: 15px; | |
color: #0c5460; | |
}} | |
.hreflang-percentage {{ | |
font-size: 1.2rem; | |
color: #0c5460; | |
}} | |
.hreflang-samples .sample-item {{ | |
padding: 5px 0; | |
font-size: 0.9rem; | |
color: #0c5460; | |
}} | |
.hreflang-samples .url {{ | |
color: #007bff; | |
margin-right: 10px; | |
}} | |
</style> | |
</head> | |
<body> | |
<div class="report-container"> | |
<div class="report-header"> | |
<h1>π SEO Analysis Report</h1> | |
<p>{url}</p> | |
<p>Generated on {generated_date}</p> | |
</div> | |
<div class="section"> | |
<h2>π Executive Summary</h2> | |
{executive_summary} | |
</div> | |
<div class="section"> | |
<h2>π Performance Charts</h2> | |
{charts} | |
</div> | |
<div class="section"> | |
<h2>β‘ Technical SEO</h2> | |
{technical_section} | |
</div> | |
<div class="section"> | |
<h2>π Content Audit</h2> | |
{content_section} | |
</div> | |
<div class="section"> | |
<h2>π Keywords Analysis</h2> | |
{keywords_section} | |
</div> | |
<div class="section"> | |
<h2>π Backlinks Profile</h2> | |
{backlinks_section} | |
</div> | |
{competitor_section} | |
<div class="section"> | |
{recommendations} | |
</div> | |
{llm_recommendations} | |
</div> | |
</body> | |
</html> | |
""" |