#!/usr/bin/env python3 """ Google SEO Report Generator - Professional PDF/HTML reports from API data. Consumes JSON output from seo-google scripts and generates formatted reports with charts, analytics, and actionable recommendations. Usage: python google_report.py --type cwv-audit --data cwv-data.json --domain example.com python google_report.py --type gsc-performance --data gsc-data.json --domain example.com python google_report.py --type indexation --data inspect-data.json --domain example.com python google_report.py --type full --data full-data.json --domain example.com cat data.json | python google_report.py --type cwv-audit --domain example.com """ import argparse import json import os import sys from datetime import datetime from html import escape from pathlib import Path from typing import Optional try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.patches as mpatches import numpy as np _CHART_IMPORT_ERROR = None except (ImportError, OSError, RuntimeError) as exc: matplotlib = plt = mpatches = np = None _CHART_IMPORT_ERROR = exc try: from weasyprint import HTML _PDF_IMPORT_ERROR = None except (ImportError, OSError) as exc: HTML = None _PDF_IMPORT_ERROR = exc # ─── Brand Colors ──────────────────────────────────────────────────────────── BRAND = { "primary": "#1e3a5f", # Navy (headers, borders) "secondary": "#4a5568", # Warm gray "accent": "#b8860b", # Dark gold "success": "#2d6a4f", # Forest green (pass/good) "warning": "#d4740e", # Warm amber (warnings) "danger": "#c53030", # Deep red (fail/critical) "dark": "#1a1a2e", # Dark navy "light_bg": "#faf9f7", # Warm cream "grid": "#d6d3cc", # Warm border "muted": "#6b7280", # Muted text } def _score_color(score): """Return brand color based on Lighthouse-style score thresholds.""" if score >= 90: return BRAND["success"] elif score >= 50: return BRAND["warning"] return BRAND["danger"] def _rating_color(rating): """Return brand color based on CrUX rating strings.""" r = str(rating).lower().replace("-", "_").replace(" ", "_") if r in ("good", "pass", "fast"): return BRAND["success"] elif r in ("needs_improvement", "needs-improvement", "average", "warn"): return BRAND["warning"] return BRAND["danger"] def _score_class(score): """Return CSS class for TOC score badges.""" if score >= 80: return "score-good" elif score >= 50: return "score-warn" return "score-bad" def _rating_css_class(rating): """Return CSS status class from a rating string.""" r = str(rating).lower() if "good" in r or "pass" in r: return "status-pass" elif "poor" in r or "fail" in r: return "status-fail" return "status-warn" GSC_ANOMALY_START = "2025-05-13" GSC_ANOMALY_END = "2026-04-27" GSC_ANOMALY_WARNING = ( "GSC impressions logging error affected impressions, CTR, and average " "position from 2025-05-13 through 2026-04-27; clicks were not affected." ) def _date_range_overlaps(start_date: str, end_date: str, overlap_start: str, overlap_end: str) -> bool: try: start = datetime.strptime(start_date, "%Y-%m-%d").date() end = datetime.strptime(end_date, "%Y-%m-%d").date() window_start = datetime.strptime(overlap_start, "%Y-%m-%d").date() window_end = datetime.strptime(overlap_end, "%Y-%m-%d").date() except (TypeError, ValueError): return False return start <= window_end and end >= window_start def _gsc_anomaly_warning(gsc_data: dict) -> str: dr = gsc_data.get("date_range", {}) if isinstance(gsc_data, dict) else {} if _date_range_overlaps( dr.get("start"), dr.get("end"), GSC_ANOMALY_START, GSC_ANOMALY_END ): return GSC_ANOMALY_WARNING return "" # ─── Chart Setup ───────────────────────────────────────────────────────────── def _setup_matplotlib(): """Configure matplotlib for professional chart output.""" plt.rcParams.update({ "font.family": "serif", "font.serif": ["DejaVu Serif", "Times New Roman", "Georgia"], "font.size": 11, "axes.titlesize": 14, "axes.titleweight": "bold", "axes.labelsize": 11, "axes.facecolor": "white", "figure.facecolor": "white", "axes.grid": False, "axes.spines.top": False, "axes.spines.right": False, }) if plt is not None: _setup_matplotlib() def _require_chart_dependencies() -> None: """Raise a runtime error only when a requested report needs charts.""" if plt is None or np is None: raise RuntimeError( "matplotlib and numpy are required for chart generation. " "Install the report dependencies from requirements.txt." ) from _CHART_IMPORT_ERROR # ─── Chart Functions ───────────────────────────────────────────────────────── def chart_lighthouse_gauges(data: dict, output_dir: Path) -> str: """Generate 2x2 Lighthouse score gauges.""" scores = data.get("lighthouse_scores", {}) if not scores: return "" _require_chart_dependencies() fig, axes = plt.subplots(2, 2, figsize=(8, 4), subplot_kw={"projection": "polar"}) categories = [ ("performance", "Performance"), ("accessibility", "Accessibility"), ("best-practices", "Best Practices"), ("seo", "SEO"), ] for ax, (key, label) in zip(axes.flat, categories): score = scores.get(key, 0) theta_bg = np.linspace(np.pi, 0, 100) theta_fill = np.linspace(np.pi, np.pi - (score / 100) * np.pi, 100) ax.plot(theta_bg, [1] * 100, linewidth=14, color="#e2e8f0", solid_capstyle="round") ax.plot(theta_fill, [1] * 100, linewidth=14, color=_score_color(score), solid_capstyle="round") ax.text(np.pi / 2, 0.35, f"{score}", ha="center", va="center", fontsize=24, fontweight="bold", color=BRAND["dark"]) ax.text(np.pi / 2, -0.05, label, ha="center", va="center", fontsize=10, color=BRAND["muted"]) ax.set_ylim(0, 1.3) ax.set_rticks([]) ax.set_thetagrids([]) ax.spines["polar"].set_visible(False) plt.tight_layout(pad=2) path = output_dir / "lighthouse_gauges.png" plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white") plt.close() return str(path) def chart_cwv_distributions(data: dict, output_dir: Path) -> str: """Generate stacked horizontal bars for CWV metric distributions.""" crux = data.get("crux", {}) metrics = crux.get("metrics", {}) if not metrics: return "" cwv_order = [ "largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift", "first_contentful_paint", "experimental_time_to_first_byte", ] labels, goods, nis, poors = [], [], [], [] for name in cwv_order: m = metrics.get(name) if not m or "distribution" not in m: continue d = m["distribution"] labels.append(m.get("label", name)) goods.append(d.get("good", 0)) nis.append(d.get("needs_improvement", 0)) poors.append(d.get("poor", 0)) if not labels: return "" _require_chart_dependencies() fig, ax = plt.subplots(figsize=(8, max(2.5, len(labels) * 0.7))) y = range(len(labels)) ax.barh(y, goods, color=BRAND["success"], label="Good", height=0.5) ax.barh(y, nis, left=goods, color=BRAND["warning"], label="Needs Improvement", height=0.5) left2 = [g + n for g, n in zip(goods, nis)] ax.barh(y, poors, left=left2, color=BRAND["danger"], label="Poor", height=0.5) ax.set_yticks(y) ax.set_yticklabels(labels) ax.set_xlim(0, 100) ax.set_xlabel("% of page loads") ax.legend(loc="lower right", fontsize=9) ax.invert_yaxis() for i, (g, n, p) in enumerate(zip(goods, nis, poors)): if g > 10: ax.text(g / 2, i, f"{g:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold") if n > 10: ax.text(g + n / 2, i, f"{n:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold") if p > 10: ax.text(g + n + p / 2, i, f"{p:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold") plt.tight_layout() path = output_dir / "cwv_distributions.png" plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white") plt.close() return str(path) def chart_cwv_timeline(data: dict, output_dir: Path) -> str: """Generate CWV timeline chart from CrUX History data.""" metrics = data.get("metrics", {}) periods = data.get("collection_periods", []) if not metrics or not periods: return "" cwv_metrics = [ "largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift", ] available = [m for m in cwv_metrics if m in metrics] if not available: return "" _require_chart_dependencies() fig, axes = plt.subplots(len(available), 1, figsize=(10, 3 * len(available)), sharex=True) if len(available) == 1: axes = [axes] x_labels = [p.get("last", "")[-5:] for p in periods] # MM-DD format x = range(len(x_labels)) for ax, metric_name in zip(axes, available): m = metrics[metric_name] p75s = m.get("p75_values", []) label = m.get("label", metric_name) good_t = m.get("good_threshold", 0) poor_t = m.get("poor_threshold", 0) valid_x = [i for i, v in enumerate(p75s) if v is not None] valid_y = [v for v in p75s if v is not None] if not valid_y: continue # Threshold bands if good_t and poor_t: ax.axhspan(0, good_t, alpha=0.1, color=BRAND["success"]) ax.axhspan(good_t, poor_t, alpha=0.1, color=BRAND["warning"]) ax.axhline(y=good_t, color=BRAND["success"], linestyle="--", alpha=0.5, linewidth=1) ax.axhline(y=poor_t, color=BRAND["danger"], linestyle="--", alpha=0.5, linewidth=1) ax.plot(valid_x, valid_y, color=BRAND["primary"], linewidth=2, marker="o", markersize=3) ax.fill_between(valid_x, valid_y, alpha=0.1, color=BRAND["primary"]) unit = m.get("unit", "") ax.set_ylabel(f"{label} (p75{unit})") ax.set_title(label, fontsize=12, fontweight="bold") if x_labels: step = max(1, len(x_labels) // 8) axes[-1].set_xticks(range(0, len(x_labels), step)) axes[-1].set_xticklabels( [x_labels[i] for i in range(0, len(x_labels), step)], rotation=45, fontsize=8, ) plt.tight_layout() path = output_dir / "cwv_timeline.png" plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white") plt.close() return str(path) def chart_top_queries(data: dict, output_dir: Path) -> str: """Generate horizontal bar chart of top queries by impressions.""" rows = data.get("rows", []) if not rows: return "" # Sort by impressions (more meaningful than clicks for new sites) top = sorted(rows, key=lambda r: r.get("impressions", 0), reverse=True)[:12] top = [r for r in top if r.get("impressions", 0) > 0] if not top: return "" labels = [r.get("query", r.get("keys", ["?"])[0])[:35] for r in top] impressions = [r.get("impressions", 0) for r in top] clicks = [r.get("clicks", 0) for r in top] if not impressions or max(impressions) < 3: return "" _require_chart_dependencies() fig, ax = plt.subplots(figsize=(7, max(2, len(labels) * 0.3))) y = range(len(labels)) bars = ax.barh(y, impressions, color=BRAND["primary"], height=0.55, label="Impressions") if any(c > 0 for c in clicks): ax.barh(y, clicks, color=BRAND["success"], height=0.55, label="Clicks") ax.legend(fontsize=8, loc="lower right") ax.set_yticks(y) ax.set_yticklabels(labels, fontsize=8) ax.set_xlabel("Count") ax.invert_yaxis() for bar, val in zip(bars, clicks): if val > 0: ax.text( bar.get_width() + max(clicks) * 0.02, bar.get_y() + bar.get_height() / 2, str(val), va="center", fontsize=8, color=BRAND["dark"], ) plt.tight_layout() path = output_dir / "top_queries.png" plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white") plt.close() return str(path) def chart_index_status(data: dict, output_dir: Path) -> str: """Generate donut chart for URL inspection results.""" summary = data.get("summary", {}) if not summary: return "" labels, sizes, colors = [], [], [] for key, label, color in [ ("pass", "Indexed", BRAND["success"]), ("fail", "Not Indexed", BRAND["danger"]), ("neutral", "Neutral", BRAND["grid"]), ("error", "Error", BRAND["muted"]), ]: val = summary.get(key, 0) if val > 0: labels.append(f"{label} ({val})") sizes.append(val) colors.append(color) if not sizes: return "" _require_chart_dependencies() fig, ax = plt.subplots(figsize=(4.5, 3.5)) wedges, texts, autotexts = ax.pie( sizes, labels=labels, colors=colors, autopct="%1.0f%%", startangle=90, pctdistance=0.75, textprops={"fontsize": 9}, ) centre = plt.Circle((0, 0), 0.50, fc="white") ax.add_artist(centre) total = sum(sizes) ax.text(0, 0, f"{total}\nURLs", ha="center", va="center", fontsize=16, fontweight="bold", color=BRAND["dark"]) plt.tight_layout() path = output_dir / "index_status.png" plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white") plt.close() return str(path) # ─── CSS Template ──────────────────────────────────────────────────────────── def _build_css(domain: str) -> str: """ Professional A4 report CSS adapted from generate_pdf.py. Uses Times New Roman / DejaVu Serif body font. All proven layout classes from the dexdia.com audit report are preserved with the WeasyPrint-safe patterns (div.section for page-break, float:right for badges, display:table for two-column layouts). """ return f"""\ @page {{ size: A4; margin: 22mm 18mm 25mm 18mm; @bottom-center {{ content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #6b7280; font-family: 'DejaVu Serif', 'Times New Roman', Georgia, serif; }} @bottom-left {{ content: "Confidential"; font-size: 7pt; color: #d6d3cc; font-family: 'DejaVu Serif', 'Times New Roman', Georgia, serif; }} @bottom-right {{ content: "{domain} Google SEO Report"; font-size: 8pt; color: #cbd5e1; font-family: 'DejaVu Serif', 'Times New Roman', Georgia, serif; }} }} @page :first {{ margin: 0; @bottom-left {{ content: none; }} @bottom-center {{ content: none; }} @bottom-right {{ content: none; }} }} @page toc {{ @bottom-center {{ content: counter(page); font-size: 9pt; color: #6b7280; }} }} * {{ box-sizing: border-box; margin: 0; padding: 0; }} body {{ font-family: 'Times New Roman', 'DejaVu Serif', Georgia, serif; font-size: 10pt; line-height: 1.55; color: #1e293b; background: white; }} /* ─── Title Page (Clean White) ─── */ .title-page {{ page: first; width: 210mm; height: 297mm; background: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; color: #1a1a2e; position: relative; padding: 50mm 30mm 40mm 30mm; border-top: 6mm solid #1e3a5f; }} .title-page .badge {{ background: #f7f6f3; border: 1px solid #d6d3cc; border-radius: 20px; padding: 6px 18px; font-size: 9pt; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 15mm; color: #1e3a5f; }} .title-page h1 {{ font-size: 28pt; font-weight: bold; margin-bottom: 5mm; letter-spacing: -0.5px; line-height: 1.2; color: #1a1a2e; }} .title-page .subtitle {{ font-size: 13pt; color: #6b7280; margin-bottom: 12mm; font-weight: 300; }} .title-page .url {{ font-size: 12pt; color: #1e3a5f; margin-bottom: 15mm; padding: 4mm 8mm; border: 1px solid #d6d3cc; border-radius: 6px; background: #faf9f7; }} .title-page .score-box {{ background: #faf9f7; border: 2px solid #d6d3cc; border-radius: 12px; padding: 6mm 12mm; margin-bottom: 15mm; }} .title-page .score-number {{ font-size: 42pt; font-weight: bold; color: #1e3a5f; line-height: 1; }} .title-page .score-label {{ font-size: 10pt; color: #6b7280; margin-top: 2mm; }} .title-page .meta {{ font-size: 9pt; color: #6b7280; margin-top: 8mm; padding-top: 5mm; border-top: 1px solid #d6d3cc; }} .title-page .meta span {{ margin: 0 8px; }} /* ─── Table of Contents ─── */ .toc-page {{ page: toc; page-break-before: always; }} .toc-page h2 {{ font-size: 18pt; color: #1e3a5f; margin-bottom: 8mm; padding-bottom: 3mm; border-bottom: 2px solid #1e3a5f; }} .toc-list {{ list-style: none; padding: 0; }} .toc-list li {{ padding: 2mm 0; border-bottom: 1px solid #f1f5f9; overflow: hidden; }} .toc-list li.toc-section {{ font-weight: bold; font-size: 11pt; padding-top: 4mm; color: #0f172a; }} .toc-list li.toc-sub {{ padding-left: 8mm; font-size: 9.5pt; color: #475569; }} .toc-score {{ display: inline-block; float: right; padding: 1px 8px; border-radius: 10px; font-size: 9pt; font-weight: bold; color: white; }} .score-good {{ background: #2d6a4f; }} .score-warn {{ background: #d4740e; }} .score-bad {{ background: #c53030; }} /* ─── Section Styles ─── */ div.section {{ page-break-before: always; }} .section-header {{ background: #faf9f7; border-left: 4px solid #1e3a5f; padding: 5mm 6mm; margin-bottom: 6mm; page-break-after: avoid; }} .section-header h2 {{ font-size: 16pt; color: #0f172a; margin-bottom: 1mm; }} .section-header .section-score {{ font-size: 12pt; font-weight: bold; float: right; margin-top: -6mm; }} h3 {{ font-size: 12pt; color: #1e3a5f; margin-top: 6mm; margin-bottom: 3mm; padding-bottom: 1.5mm; border-bottom: 1px solid #d6d3cc; page-break-after: avoid; }} h4 {{ font-size: 10.5pt; color: #334155; margin-top: 4mm; margin-bottom: 2mm; page-break-after: avoid; }} p {{ margin-bottom: 3mm; color: #334155; }} .highlight {{ background: #fef3c7; border-left: 3px solid #d4740e; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; /* allow page breaks to prevent white gaps */ }} .critical-box {{ background: #fef2f2; border-left: 3px solid #c53030; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; /* allow page breaks to prevent white gaps */ }} .success-box {{ background: #f0fdf4; border-left: 3px solid #2d6a4f; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; /* allow page breaks to prevent white gaps */ }} /* ─── Tables ─── */ table {{ width: 100%; border-collapse: collapse; margin: 4mm 0 6mm 0; font-size: 9pt; }} thead th {{ background: #f7f6f3; color: #0f172a; font-weight: bold; padding: 3mm 4mm; text-align: left; border-bottom: 2px solid #d6d3cc; font-size: 9pt; }} tbody td {{ padding: 2.5mm 3mm; border-bottom: 1px solid #f1f5f9; vertical-align: top; }} tbody tr:nth-child(even) {{ background: #fdfcfa; }} .status-pass {{ color: #2d6a4f; font-weight: bold; }} .status-fail {{ color: #c53030; font-weight: bold; }} .status-warn {{ color: #d4740e; font-weight: bold; }} .status-partial {{ color: #4a5568; font-weight: bold; }} /* ─── Charts ─── */ .chart-container {{ text-align: center; margin: 4mm 0; }} .chart-container img {{ max-width: 100%; max-height: 120mm; height: auto; }} .chart-caption {{ font-size: 8.5pt; color: #6b7280; font-style: italic; margin-top: 2mm; text-align: center; }} .chart-half {{ display: inline-block; width: 48%; vertical-align: top; text-align: center; margin: 2mm 0; }} .chart-half img {{ max-width: 100%; height: auto; }} /* ─── Two column layout ─── */ .two-col {{ display: table; width: 100%; table-layout: fixed; margin: 3mm 0; }} .two-col .col {{ display: table-cell; vertical-align: top; padding: 0 2mm; }} .four-col {{ display: table; width: 100%; table-layout: fixed; margin: 3mm 0; }} .four-col .col {{ display: table-cell; vertical-align: top; padding: 0 1.5mm; }} /* ─── Metric Cards ─── */ .metric-card {{ background: #faf9f7; border: 1px solid #d6d3cc; border-radius: 6px; padding: 2.5mm 3mm; text-align: center; margin: 2mm 0; }} .metric-card .value {{ font-size: 14pt; font-weight: bold; line-height: 1.2; }} .metric-card .label {{ font-size: 7.5pt; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1mm; }} /* ─── Action Plan ─── */ .action-item {{ background: #faf9f7; border-radius: 4px; padding: 3mm 4mm; margin: 3mm 0; border-left: 3px solid #cbd5e1; /* allow page breaks to prevent white gaps */ }} .action-item.critical {{ border-left-color: #c53030; background: #fdf2f2; }} .action-item.high {{ border-left-color: #d4740e; background: #fdf8ef; }} .action-item.medium {{ border-left-color: #1e3a5f; background: #f0f4f8; }} .action-item.low {{ border-left-color: #94a3b8; }} .action-item h4 {{ margin-top: 0; margin-bottom: 1.5mm; border-bottom: none; padding-bottom: 0; }} .action-item .effort {{ font-size: 8.5pt; color: #64748b; float: right; }} .priority-tag {{ display: inline-block; padding: 0.5mm 3mm; border-radius: 3px; font-size: 8pt; font-weight: bold; color: white; margin-right: 2mm; vertical-align: middle; }} .priority-critical {{ background: #c53030; }} .priority-high {{ background: #d4740e; }} .priority-medium {{ background: #1e3a5f; }} .priority-low {{ background: #94a3b8; }} /* ─── Code blocks ─── */ .code-block {{ background: #1e293b; color: #e2e8f0; padding: 3mm 4mm; border-radius: 4px; font-family: 'DejaVu Sans Mono', monospace; font-size: 8pt; line-height: 1.6; margin: 3mm 0; white-space: pre-wrap; word-break: break-all; /* allow page breaks to prevent white gaps */ }} /* ─── Divider ─── */ .divider {{ border: none; border-top: 1px solid #d6d3cc; margin: 5mm 0; }} /* ─── Roadmap ─── */ .roadmap-phase {{ background: #faf9f7; border-radius: 6px; padding: 4mm 5mm; margin: 4mm 0; border: 1px solid #d6d3cc; /* allow page breaks to prevent white gaps */ }} .roadmap-phase h4 {{ margin-top: 0; border-bottom: none; color: #1e3a5f; }} .roadmap-phase ul {{ margin: 2mm 0 0 5mm; padding: 0; }} .roadmap-phase li {{ margin-bottom: 1.5mm; font-size: 9.5pt; color: #334155; }} /* ─── Lists ─── */ ul {{ margin-left: 5mm; margin-bottom: 3mm; }} li {{ margin-bottom: 1.5mm; }} /* ─── Data freshness ─── */ .data-freshness {{ font-size: 8pt; color: #6b7280; font-style: italic; margin-top: 4mm; padding-top: 2mm; border-top: 1px solid #d6d3cc; }} """ # ─── HTML Helpers ──────────────────────────────────────────────────────────── def _img_tag(path, alt="Chart"): """Convert file path to file:// URI img tag for WeasyPrint.""" if not path: return "" return f'{alt}' def _chart_html(path, caption, fig_num, alt="Chart"): """Build a complete chart container with figure caption.""" if not path: return "" return ( f'
\n' f' {alt}\n' f'
Figure {fig_num}: {caption}
\n' f'
\n' ) def _metric_card(value, label, color=None): """Build a metric card HTML block.""" style = f' style="color: {color};"' if color else "" return ( f'
\n' f'
{value}
\n' f'
{label}
\n' f'
\n' ) # ─── Section Builders ──────────────────────────────────────────────────────── def _build_title_page(domain, report_title, subtitle, score=None, score_label=None, meta_items=None): """Build the gradient title page.""" score_html = "" if score is not None: score_html = ( f'
\n' f'
{score}' f'/100
\n' f'
{score_label or "Lighthouse Performance Score"}
\n' f'
\n' ) meta_html = "" if meta_items: spans = "\n ".join(f"{item}" for item in meta_items) meta_html = ( f'
\n' f' {spans}\n' f'
\n' ) # Google logo (if available in charts dir) google_logo_path = Path(__file__).parent.parent / "charts" / "google_logo.png" google_logo_html = "" if google_logo_path.exists(): google_logo_html = ( f'
\n' f' Google\n' f'
Powered by Google APIs
\n' f'
\n' ) return ( f'\n\n' f'
\n' f'
{report_title}
\n' f'

{domain}

\n' f'
Prepared by Claude SEO
\n' f'{score_html}' f'{meta_html}' f'{google_logo_html}' f'
\n' ) def _build_toc(sections_info): """ Build a Table of Contents page. sections_info: list of dicts with keys 'num', 'title', 'score' (optional), and 'subs' (list of subtitle strings). """ items = [] for sec in sections_info: score_html = "" if sec.get("score") is not None: cls = _score_class(sec["score"]) score_html = f' {sec["score"]}' items.append( f'
  • ' f'{sec["num"]}. {sec["title"]}{score_html}' f'
  • ' ) for sub in sec.get("subs", []): items.append(f'
  • {sub}
  • ') items_html = "\n".join(items) return ( f'\n\n' f'
    \n' f'

    Table of Contents

    \n' f' \n' f'
    \n' ) def _coerce_items(value): """Return a list for scalar-or-list audit fields.""" if value is None: return [] if isinstance(value, list): return value return [value] def _finding_title(item): if isinstance(item, dict): return item.get("title") or item.get("issue") or item.get("name") or "Finding" return str(item) def _finding_severity(item): if isinstance(item, dict): return str(item.get("severity", "Info")) return "Info" def _finding_description(item): if isinstance(item, dict): return item.get("description") or item.get("details") or item.get("evidence") or "" return "" def _build_full_audit_categories(data, section_num=2): """Build category sections for audit-data.json style reports.""" categories = _coerce_items(data.get("categories")) if not categories: return "" lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Audit Categories

    ') lines.append('
    ') lines.append('') for idx, category in enumerate(categories, 1): if not isinstance(category, dict): continue name = escape(str(category.get("name", f"Category {idx}"))) score = category.get("score") lines.append(f'

    {section_num}.{idx} {name}

    ') if score is not None: try: score_val = float(score) cls = "status-pass" if score_val >= 80 else ("status-warn" if score_val >= 50 else "status-fail") lines.append(f'

    Score: {score_val:g}/100

    ') except (TypeError, ValueError): lines.append(f'

    Score: {escape(str(score))}

    ') what_works = _coerce_items(category.get("what_works")) if what_works: lines.append('

    What Works

    ') lines.append(' ') findings = _coerce_items(category.get("findings")) if findings: lines.append('

    Findings

    ') for finding in findings: title = escape(_finding_title(finding)) severity = escape(_finding_severity(finding)) desc = escape(_finding_description(finding)) recommendation = "" if isinstance(finding, dict) and finding.get("recommendation"): recommendation = escape(str(finding["recommendation"])) severity_class = _rating_css_class(severity) lines.append('
    ') lines.append(f'

    {title} {severity}

    ') if desc: lines.append(f'

    {desc}

    ') if recommendation: lines.append(f'

    Recommendation: {recommendation}

    ') lines.append('
    ') lines.append('
    ') lines.append('
    ') return "\n".join(lines) def _build_audit_action_plan(data, section_num=5): """Build a four-phase or custom action-plan section.""" action_plan = data.get("action_plan", {}) phases = [] if isinstance(action_plan, dict): phases = _coerce_items(action_plan.get("phases") or action_plan.get("roadmap")) elif isinstance(action_plan, list): phases = action_plan if not phases: return "" lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Action Plan

    ') lines.append('
    ') lines.append('') for idx, phase in enumerate(phases, 1): if isinstance(phase, dict): name = phase.get("name") or phase.get("phase") or f"Phase {idx}" timeframe = phase.get("timeframe") or phase.get("timeline") or "" items = _coerce_items(phase.get("items") or phase.get("actions")) else: name = f"Phase {idx}" timeframe = "" items = [phase] lines.append('
    ') heading = escape(str(name)) if timeframe: heading = f'{heading} {escape(str(timeframe))}' lines.append(f'

    {heading}

    ') lines.append(' ') lines.append('
    ') lines.append('
    ') return "\n".join(lines) def _build_executive_summary(domain, timestamp, data, report_type): """Build the Executive Summary section with metric cards, issues, and wins.""" lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append('

    1. Executive Summary

    ') lines.append('
    ') lines.append('') summary = data.get("summary", {}) if isinstance(data.get("summary"), dict) else {} # Context paragraph if summary: business_type = summary.get("business_type") type_text = f' for a {escape(str(business_type))} site' if business_type else "" lines.append(f'

    This report presents a comprehensive SEO audit of ' f'{domain}{type_text}, generated on {timestamp}. ' f'Findings combine technical, content, schema, performance, visual, ' f'and search-readiness evidence as available.

    ') else: lines.append(f'

    This report presents a comprehensive Google SEO analysis of ' f'{domain}, generated on {timestamp}. ' f'Data was collected from Google PageSpeed Insights, Chrome User Experience ' f'Report (CrUX), Google Search Console, and the URL Inspection API as available.

    ') lines.append('') # Metric cards row cards = [] health_score = summary.get("health_score") if health_score is not None: cards.append(("health", f"{health_score}/100", "SEO Health Score", _score_color(float(health_score)))) # PSI performance score psi = data.get("psi", {}) mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {} perf_score = mobile.get("lighthouse_scores", {}).get("performance") if perf_score is not None: color = _score_color(perf_score) cards.append(("perf", f"{perf_score}/100", "Lighthouse Performance", color)) seo_score = mobile.get("lighthouse_scores", {}).get("seo") if seo_score is not None: color = _score_color(seo_score) cards.append(("seo", f"{seo_score}/100", "Lighthouse SEO", color)) # GSC totals gsc = data.get("gsc", {}) if gsc.get("totals"): clicks = gsc["totals"].get("clicks", 0) cards.append(("clicks", f"{clicks:,}", "Total Clicks", BRAND["primary"])) impr = gsc["totals"].get("impressions", 0) cards.append(("impr", f"{impr:,}", "Impressions", BRAND["secondary"])) # Indexation inspection = data.get("inspection", {}) if inspection.get("summary"): indexed = inspection["summary"].get("pass", 0) total = inspection.get("total", 0) cards.append(("idx", f"{indexed}/{total}", "Indexed URLs", BRAND["success"])) # Render metric cards in a compact row if cards: col_class = "four-col" if len(cards) >= 4 else "two-col" display_cards = cards[:5] lines.append(f'
    ') for _, val, lbl, clr in display_cards: lines.append(f'
    ') lines.append(_metric_card(val, lbl, clr)) lines.append(f'
    ') lines.append('
    ') lines.append('') # Critical issues box issues = [] for item in _coerce_items(summary.get("top_findings")): severity = _finding_severity(item) title = _finding_title(item) issues.append(f'{escape(severity)}: {escape(title)}') failed_audits = mobile.get("failed_audits", []) if failed_audits: top_fail = sorted(failed_audits, key=lambda a: a.get("score", 1))[:3] for a in top_fail: issues.append(f'{a.get("title", "Unknown")} ' f'(score: {a.get("score", 0):.0%})') seo_audits = mobile.get("seo_audits", []) seo_failed = [a for a in seo_audits if not a.get("pass")] for a in seo_failed[:2]: issues.append(f'SEO: {a.get("title", "Unknown")}') inspect_fails = inspection.get("summary", {}).get("fail", 0) if inspect_fails: issues.append(f'{inspect_fails} URL(s) not indexed') if issues: issue_items = "\n".join(f"
  • {i}
  • " for i in issues[:5]) lines.append('
    ') lines.append(' Critical Issues Found:') lines.append('
      ') lines.append(issue_items) lines.append('
    ') lines.append('
    ') lines.append('') # Quick wins box wins = [] for item in _coerce_items(summary.get("quick_wins")): wins.append(escape(str(item))) qw = gsc.get("quick_wins", []) if qw: wins.append(f'{len(qw)} search queries at positions 4-10 with high impressions ' f'(small ranking bump = significant traffic)') opps = mobile.get("opportunities", []) for o in opps[:3]: savings = o.get("savings_ms", 0) if savings: wins.append(f'{o.get("title", "Optimization")}: save ~{savings}ms') if wins: win_items = "\n".join(f"
  • {w}
  • " for w in wins[:5]) lines.append('
    ') lines.append(' Quick Wins:') lines.append('
      ') lines.append(win_items) lines.append('
    ') lines.append('
    ') lines.append('') lines.append('
    ') return "\n".join(lines) def _build_cwv_section(psi_data, crux_data, chart_paths, history_data=None, section_num=2): """Build the Core Web Vitals audit section.""" fig_counter = [1] # mutable counter for figure numbering def next_fig(): n = fig_counter[0] fig_counter[0] += 1 return n lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Core Web Vitals & Performance

    ') # Section score from Lighthouse performance psi = psi_data if isinstance(psi_data, dict) else {} mobile = psi.get("psi", {}).get("mobile", psi) scores = mobile.get("lighthouse_scores", {}) perf = scores.get("performance") if perf is not None: color = _score_color(perf) lines.append(f'
    {perf}/100
    ') lines.append('
    ') lines.append('') # 2.1 Lighthouse Scores gauges if scores: lines.append(f'

    {section_num}.1 Lighthouse Scores

    ') lines.append('') gauges_path = chart_paths.get("gauges_path", "") lines.append(_chart_html( gauges_path, "Lighthouse audit scores across Performance, Accessibility, Best Practices, and SEO.", next_fig(), "Lighthouse gauge scores", )) # Scores summary table lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') for key, label in [("performance", "Performance"), ("accessibility", "Accessibility"), ("best-practices", "Best Practices"), ("seo", "SEO")]: s = scores.get(key) if s is not None: cls = "status-pass" if s >= 90 else ("status-warn" if s >= 50 else "status-fail") rating = "Good" if s >= 90 else ("Needs Work" if s >= 50 else "Poor") lines.append(f' ') lines.append(' ') lines.append('
    CategoryScoreRating
    {label}{s}{rating}
    ') lines.append('') # Divider between Lighthouse Scores and Lab Metrics lines.append('
    ') # 2.2 Lab Metrics lab = mobile.get("lab_metrics", {}) if lab: lines.append(f'

    {section_num}.2 Lab Metrics (Simulated)

    ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') metric_labels = { "first-contentful-paint": "First Contentful Paint (FCP)", "largest-contentful-paint": "Largest Contentful Paint (LCP)", "total-blocking-time": "Total Blocking Time (TBT)", "cumulative-layout-shift": "Cumulative Layout Shift (CLS)", "speed-index": "Speed Index", "interactive": "Time to Interactive (TTI)", } thresholds = { "first-contentful-paint": "\u2264 1.8s", "largest-contentful-paint": "\u2264 2.5s", "total-blocking-time": "\u2264 200ms", "cumulative-layout-shift": "\u2264 0.1", "speed-index": "\u2264 3.4s", "interactive": "\u2264 3.8s", } for k, v in lab.items(): score_val = v.get("score") score_pct = f"{score_val:.0%}" if score_val is not None else "N/A" cls = ("status-pass" if score_val and score_val >= 0.9 else ("status-warn" if score_val and score_val >= 0.5 else "status-fail")) label = metric_labels.get(k, k.replace("-", " ").title()) threshold = thresholds.get(k, "\u2014") lines.append(f' ' f'') lines.append(' ') lines.append('
    MetricValueScoreThreshold
    {label}{v.get("display", "")}{score_pct}{threshold}
    ') lines.append('') # 2.3 CrUX Field Data crux = crux_data if isinstance(crux_data, dict) else {} crux_metrics = crux.get("metrics", {}) if crux_metrics: lines.append(f'

    {section_num}.3 CrUX Field Data (28-day Rolling Average)

    ') lines.append('') # Distribution chart dist_path = chart_paths.get("distributions_path", "") lines.append(_chart_html( dist_path, "Core Web Vitals field data distribution across Good, Needs Improvement, and Poor buckets.", next_fig(), "CWV distribution chart", )) lines.append(' ') lines.append(' ') lines.append(' ' '') lines.append(' ') lines.append(' ') for name, m in crux_metrics.items(): rating = m.get("rating", "?") dist = m.get("distribution", {}) unit = m.get("unit", "") p75 = m.get("p75", "?") display_val = f"{p75:.3f}" if name == "cumulative_layout_shift" else f"{p75}{unit}" cls = _rating_css_class(rating) lines.append(f' ') lines.append(f' ') lines.append(f' ' f'' f'') lines.append(' ') lines.append('
    Metricp75RatingGood %NI %Poor %
    {m.get("label", name)}{display_val}{rating.upper()}{dist.get("good", "N/A")}%{dist.get("needs_improvement", "N/A")}%{dist.get("poor", "N/A")}%
    ') cp = crux.get("collection_period", {}) if cp: lines.append(f'

    Collection period: {cp.get("first", "?")} ' f'to {cp.get("last", "?")}. CrUX data is a 28-day rolling average ' f'updated daily ~04:00 UTC.

    ') lines.append('') elif crux.get("error"): lines.append(f'

    {section_num}.3 CrUX Field Data

    ') lines.append(f'
    CrUX Field Data: ' f'{crux["error"]}
    ') lines.append('') # CrUX History timeline if history_data and not history_data.get("error"): lines.append(f'

    {section_num}.4 Core Web Vitals Trends (25-week)

    ') timeline_path = chart_paths.get("timeline_path", "") lines.append(_chart_html( timeline_path, "CrUX p75 values over 25 weeks with Good/Poor threshold bands.", next_fig(), "CWV timeline trends", )) trends = history_data.get("trends", {}) if trends: lines.append(' ') lines.append(' ') lines.append(' ' '') lines.append(' ') lines.append(' ') for name, t in trends.items(): direction = t.get("direction", "?") cls = ("status-pass" if direction == "improving" else ("status-fail" if direction == "degrading" else "")) lines.append(f' ' f'') lines.append(f' ' f'' f'') lines.append(' ') lines.append('
    MetricDirectionChangeEarliest AvgLatest Avg
    {t.get("label", name)}{direction.upper()}{t.get("change_pct", 0):+.1f}%{t.get("earliest_avg", "?")}{t.get("latest_avg", "?")}
    ') lines.append('') # Failed audits failed = mobile.get("failed_audits", []) if failed: sub = f"{section_num}.5" if history_data and not history_data.get("error") else f"{section_num}.4" lines.append(f'

    {sub} Failed / Warning Audits ({len(failed)})

    ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') for a in failed[:20]: score_pct = f"{a['score']:.0%}" if a.get("score") is not None else "?" lines.append(f' ' f'' f'') lines.append(' ') lines.append('
    AuditScoreDetails
    {a.get("title", "")}{score_pct}{a.get("display", "")}
    ') lines.append('') # SEO audit checks seo_audits = mobile.get("seo_audits", []) if seo_audits: seo_failed = [a for a in seo_audits if not a.get("pass")] if seo_failed: lines.append(f'

    SEO Audit Issues ({len(seo_failed)})

    ') for a in seo_failed: lines.append(f'
    ') lines.append(f'

    {a.get("title", "")}

    ') lines.append(f'
    ') else: lines.append(f'
    SEO: ' f'All {len(seo_audits)} Lighthouse SEO checks passed.
    ') lines.append('') # Accessibility issues a11y = mobile.get("accessibility_audits", []) if a11y: lines.append(f'

    Accessibility Issues ({len(a11y)})

    ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') for a in a11y: lines.append(f' ' f'') lines.append(' ') lines.append('
    IssueScore
    {a.get("title", "")}{a.get("score", 0):.0%}
    ') lines.append('') # Opportunities opps = mobile.get("opportunities", []) if opps: lines.append(f'

    Optimization Opportunities ({len(opps)})

    ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') for o in opps: lines.append(f' ' f'') lines.append(' ') lines.append('
    OpportunityEstimated Savings
    {o.get("title", "")}{o.get("savings_ms", 0)}ms
    ') lines.append('') lines.append('
    ') return "\n".join(lines), fig_counter[0] def _build_gsc_section(gsc_data, chart_paths, section_num=3, fig_start=1): """Build the GSC Search Performance section.""" fig_counter = [fig_start] gsc_warning = _gsc_anomaly_warning(gsc_data) def next_fig(): n = fig_counter[0] fig_counter[0] += 1 return n lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Search Console Performance

    ') lines.append('
    ') lines.append('') totals = gsc_data.get("totals", {}) dr = gsc_data.get("date_range", {}) if totals: domain = gsc_data.get("property", "?") lines.append(f'

    Period: {dr.get("start", "?")} to {dr.get("end", "?")} ' f'| Property: {domain}

    ') if gsc_warning: lines.append(f'
    GSC data warning: ' f'{escape(gsc_warning)}
    ') queries_count = gsc_data.get("row_count", 0) impr_total = totals.get("impressions", 0) lines.append(f'

    {domain} appeared in {queries_count} unique search queries ' f'with {impr_total:,} total impressions during this period.

    ') lines.append('') clicks_val = f'{totals.get("clicks", 0):,}' impr_val = f'{totals.get("impressions", 0):,}' ctr_val = f'{totals.get("ctr", 0)}%' rows_val = str(gsc_data.get("row_count", 0)) # Metric cards in single row lines.append(f'

    {section_num}.1 Key Metrics

    ') lines.append('
    ') lines.append(f'
    {_metric_card(clicks_val, "Total Clicks", BRAND["primary"])}
    ') lines.append(f'
    {_metric_card(impr_val, "Total Impressions", BRAND["secondary"])}
    ') lines.append(f'
    {_metric_card(ctr_val, "Average CTR", BRAND["accent"])}
    ') lines.append(f'
    {_metric_card(rows_val, "Queries Found", BRAND["dark"])}
    ') lines.append('
    ') lines.append('
    ') lines.append('') # Top queries chart queries_path = chart_paths.get("top_queries_path", "") if queries_path: lines.append(f'

    {section_num}.2 Top Queries by Impressions

    ') lines.append(_chart_html( queries_path, "Top search queries ranked by impression volume from Google Search Console (28-day period).", next_fig(), "Top queries bar chart", )) # Top queries table rows = gsc_data.get("rows", []) if rows: lines.append(f'

    {section_num}.3 Query Detail Table

    ') lines.append(' ') lines.append(' ') lines.append(' ' '') lines.append(' ') lines.append(' ') sorted_rows = sorted(rows, key=lambda r: r.get("impressions", 0), reverse=True) for i, r in enumerate(sorted_rows[:15], 1): query = r.get("query", r.get("keys", ["?"])[0]) pos = r.get("position", 0) pos_cls = ("status-pass" if pos <= 3 else ("status-warn" if pos <= 10 else "status-fail")) lines.append(f' ' f'' f'') lines.append(f' ' f'') lines.append(' ') lines.append('
    #QueryClicksImpressionsCTRPosition
    {i}{query}{r.get("clicks", 0)}{r.get("impressions", 0):,}{r.get("ctr", 0)}%{pos:.1f}
    ') lines.append('') # Position analysis if rows: top3 = len([r for r in rows if r.get("position", 99) <= 3]) top10 = len([r for r in rows if r.get("position", 99) <= 10]) beyond = len([r for r in rows if r.get("position", 99) > 10]) lines.append(f'

    {section_num}.4 Query Position Analysis

    ') lines.append('
    ') lines.append(f'
    ') lines.append(_metric_card(str(top3), "Queries in Top 3", BRAND["success"])) lines.append(f'
    ') lines.append(f'
    ') lines.append(_metric_card(str(top10), "Queries in Top 10", BRAND["warning"])) lines.append(f'
    ') lines.append('
    ') if beyond: lines.append(f'

    {beyond} queries rank beyond position 10 ' f'and may benefit from content optimization.

    ') lines.append('') # Quick wins qw = gsc_data.get("quick_wins", []) if qw: lines.append(f'

    {section_num}.5 Quick Wins ({len(qw)} opportunities)

    ') lines.append('
    These queries rank at position 4-10 with ' 'high impressions. A small ranking improvement could yield significant ' 'traffic gains.
    ') lines.append(' ') lines.append(' ') lines.append(' ' '') lines.append(' ') lines.append(' ') for w in qw: query = w.get("keys", ["?"])[0] if w.get("keys") else w.get("query", "?") lines.append(f' ' f'' f'' f'') lines.append(' ') lines.append('
    QueryPositionImpressionsClicks
    {query}{w.get("position", 0):.1f}{w.get("impressions", 0):,}{w.get("clicks", 0)}
    ') lines.append('') lines.append('

    Search Analytics data has a 2-3 day lag. ' 'Data available for ~16 months.

    ') lines.append('
    ') return "\n".join(lines), fig_counter[0] def _build_indexation_section(inspect_data, chart_paths, section_num=4, fig_start=1): """Build the Indexation Status section.""" fig_counter = [fig_start] def next_fig(): n = fig_counter[0] fig_counter[0] += 1 return n lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Indexation Status

    ') lines.append('
    ') lines.append('') summary = inspect_data.get("summary", {}) total = inspect_data.get("total", 0) if summary: # Donut chart idx_path = chart_paths.get("index_status_path", "") if idx_path: fig_n = next_fig() lines.append(f'

    {section_num}.1 Index Coverage Overview

    ') lines.append(f'
    ') lines.append(f' Index status donut chart') lines.append(f'
    Figure {fig_n}: URL indexation status distribution from Google URL Inspection API.
    ') lines.append(f'
    ') # Summary cards lines.append(f'

    Total URLs inspected: {total}

    ') lines.append('
    ') lines.append(f'
    ') lines.append(_metric_card(summary.get("pass", 0), "Indexed", BRAND["success"])) lines.append(f'
    ') lines.append(f'
    ') lines.append(_metric_card(summary.get("fail", 0), "Not Indexed", BRAND["danger"])) lines.append(f'
    ') lines.append('
    ') lines.append('') indexed = summary.get("pass", 0) not_indexed = summary.get("fail", 0) if total > 0: rate = round((indexed / total) * 100, 1) lines.append(f'

    Index Rate: {rate}% of inspected URLs are indexed by Google.

    ') if total > 0 and indexed > 0: pct = (indexed / total) * 100 cls = "success-box" if pct >= 90 else ("highlight" if pct >= 70 else "critical-box") lines.append(f'
    Index Rate: ' f'{pct:.0f}% of inspected URLs are indexed.
    ') lines.append('') # Per-URL results table results = inspect_data.get("results", []) if results: lines.append(f'

    {section_num}.2 Per-URL Results

    ') lines.append(' ') lines.append(' ') lines.append(' ' '') lines.append(' ') lines.append(' ') for r in results: verdict = r.get("verdict", "?") cls = ("status-pass" if verdict == "PASS" else ("status-fail" if verdict == "FAIL" else "")) idx = r.get("index_status", {}) cov = idx.get("coverage_state", r.get("error", "N/A")) crawl = idx.get("last_crawl_time", "N/A") if crawl and crawl != "N/A": crawl = crawl[:10] url_display = r.get("url", "?") lines.append(f' ') lines.append(f' ' f'') lines.append(' ') lines.append('
    URLVerdictCoverage StateLast Crawl
    ' f'{url_display}{verdict}{cov}{crawl}
    ') lines.append('') # Rich results rich = inspect_data.get("rich_results", []) if rich: lines.append(f'

    {section_num}.3 Rich Results Detected

    ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') lines.append(' ') for rr in rich: lines.append(f' ' f'') lines.append(' ') lines.append('
    URLRich Result Type
    ' f'{rr.get("url", "?")}{rr.get("type", "?")}
    ') lines.append('') lines.append('

    URL Inspection API: ' '2,000 inspections/day per property.

    ') lines.append('
    ') return "\n".join(lines), fig_counter[0] def _build_recommendations(data, section_num=5): """Build prioritized recommendations section based on discovered issues.""" lines = [] lines.append(f'\n') lines.append('
    ') lines.append('
    ') lines.append(f'

    {section_num}. Recommendations

    ') lines.append('
    ') lines.append('') lines.append('

    Prioritized action items based on the data collected. ' 'Items are ranked by expected impact on search visibility and user experience.

    ') lines.append('') item_num = 0 # Collect critical items critical_items = [] psi = data.get("psi", {}) mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {} perf = mobile.get("lighthouse_scores", {}).get("performance") if perf is not None and perf < 50: critical_items.append( ("Improve Lighthouse Performance Score", "Medium (2-4 hrs)", f"Current score is {perf}/100. Focus on reducing Largest Contentful Paint " f"and Total Blocking Time. Defer non-critical JavaScript and optimize images.") ) seo_failed = [a for a in mobile.get("seo_audits", []) if not a.get("pass")] for a in seo_failed[:2]: critical_items.append( (f"Fix SEO Issue: {a.get('title', 'Unknown')}", "Low (30 min)", "This Lighthouse SEO check is failing. Address it to ensure proper crawling " "and indexing by search engines.") ) inspect = data.get("inspection", {}) not_indexed = inspect.get("summary", {}).get("fail", 0) if not_indexed: critical_items.append( (f"Resolve {not_indexed} Non-Indexed URL(s)", "Medium (2-4 hrs)", "These pages are not appearing in Google's index. Review coverage state, " "fix crawl errors, and request re-indexing via Search Console.") ) if critical_items: lines.append(f'

    CRITICAL ' f'Fix Immediately

    ') for title, effort, desc in critical_items: item_num += 1 lines.append(f'
    ') lines.append(f'

    {item_num}. {title} ' f'Effort: {effort}

    ') lines.append(f'

    {desc}

    ') lines.append(f'
    ') lines.append('') # High priority items high_items = [] failed_audits = mobile.get("failed_audits", []) top_fails = sorted(failed_audits, key=lambda a: a.get("score", 1))[:5] for a in top_fails: if a.get("score", 1) < 0.5: high_items.append( (f"Address: {a.get('title', 'Unknown')}", "Medium (1-2 hrs)", f"Score: {a['score']:.0%}. {a.get('display', 'Review and optimize this audit.')}") ) opps = mobile.get("opportunities", []) for o in opps[:3]: savings = o.get("savings_ms", 0) if savings: high_items.append( (f"{o.get('title', 'Optimization')}", "Medium (2-4 hrs)", f"Potential savings of ~{savings}ms. Implement this to improve page load speed.") ) gsc = data.get("gsc", {}) qw = gsc.get("quick_wins", []) if qw: high_items.append( (f"Optimize {len(qw)} Quick-Win Queries", "Medium (2-4 hrs)", "These queries rank at positions 4-10 with high impressions. " "Improve on-page SEO and content depth to push them into top 3.") ) if high_items: lines.append(f'

    HIGH ' f'Fix Within 1 Week

    ') for title, effort, desc in high_items: item_num += 1 lines.append(f'
    ') lines.append(f'

    {item_num}. {title} ' f'Effort: {effort}

    ') lines.append(f'

    {desc}

    ') lines.append(f'
    ') lines.append('') # Medium priority items medium_items = [] a11y = mobile.get("accessibility_audits", []) if a11y: medium_items.append( (f"Fix {len(a11y)} Accessibility Issue(s)", "Low (30 min)", "Accessibility improvements benefit SEO (Lighthouse score) and user experience. " "Address failing accessibility audits.") ) acc_score = mobile.get("lighthouse_scores", {}).get("accessibility") if acc_score is not None and acc_score < 90 and not a11y: medium_items.append( ("Improve Accessibility Score", "Medium (2-4 hrs)", f"Current score: {acc_score}/100. Run a detailed accessibility audit " f"and address any violations.") ) bp_score = mobile.get("lighthouse_scores", {}).get("best-practices") if bp_score is not None and bp_score < 90: medium_items.append( ("Address Best Practices Issues", "Low (30 min)", f"Current score: {bp_score}/100. Review browser console for errors, " f"update deprecated APIs, and ensure HTTPS for all resources.") ) if medium_items: lines.append(f'

    MEDIUM ' f'Fix Within 1 Month

    ') for title, effort, desc in medium_items: item_num += 1 lines.append(f'
    ') lines.append(f'

    {item_num}. {title} ' f'Effort: {effort}

    ') lines.append(f'

    {desc}

    ') lines.append(f'
    ') lines.append('') # If no recommendations were generated at all if item_num == 0: lines.append('
    No critical issues detected. ' 'Continue monitoring Core Web Vitals and search performance regularly.
    ') lines.append('') # Implementation Roadmap lines.append('
    ') lines.append('

    Implementation Roadmap

    ') lines.append('
    ') lines.append('

    Week 1 — Quick Wins

    ') lines.append(' ') lines.append('
    ') lines.append('
    ') lines.append('

    Week 2–3 — Performance & Indexation

    ') lines.append(' ') lines.append('
    ') lines.append('
    ') lines.append('

    Week 4 — Content & Search Optimization

    ') lines.append(' ') lines.append('
    ') lines.append('') lines.append('
    ') return "\n".join(lines) def _build_methodology_footer(domain, timestamp, gsc_warning=""): """Build the Data Sources & Methodology footer section.""" warning_html = "" if gsc_warning: warning_html = ( f'

    GSC data warning: ' f'{escape(gsc_warning)}

    \n' ) return ( f'\n\n' f'
    \n' f'
    \n' f'

    Data Sources & Methodology

    \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f'
    SourceDescriptionUpdate Frequency
    PageSpeed Insights APILighthouse lab audit (mobile emulation, Moto G Power, slow 4G)Real-time
    Chrome UX Report (CrUX)28-day rolling field data from real Chrome usersDaily ~04:00 UTC
    CrUX History API25-week p75 trend data per metricWeekly
    Google Search ConsoleSearch Analytics (clicks, impressions, CTR, position)2-3 day lag
    URL Inspection APIPer-URL index status, coverage state, crawl infoReal-time (2,000/day)
    \n' f'{warning_html}' f'

    \n' f' Report generated by Claude SEO — Google SEO Intelligence Skill — ' f'{timestamp}
    \n' f' Methodology based on Google Web Vitals thresholds, Search Console documentation, ' f'and Lighthouse scoring algorithms.\n' f'

    \n' f'
    \n' ) # ─── Report Assemblers ─────────────────────────────────────────────────────── def generate_report(report_type, data, domain, output_dir, output_format="pdf"): """ Generate a complete professional PDF/HTML report. Args: report_type: 'cwv-audit', 'gsc-performance', 'indexation', or 'full'. data: Dictionary with all input data. domain: Domain name for the report header. output_dir: Directory for output files. output_format: 'pdf', 'html', or 'both'. Returns: Dictionary with output paths. """ output_dir = Path(output_dir) charts_dir = output_dir / "charts" charts_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%B %d, %Y") timestamp_short = datetime.now().strftime("%Y-%m-%d %H:%M") result = {"report_type": report_type, "domain": domain, "files": [], "error": None} # ── Generate Charts ────────────────────────────────────────────────────── chart_paths = {} try: if report_type in ("cwv-audit", "full"): psi = data.get("psi", data) mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {} path = chart_lighthouse_gauges(mobile, charts_dir) if path: chart_paths["gauges_path"] = path crux = data.get("crux", {}) path = chart_cwv_distributions({"crux": crux} if crux else data, charts_dir) if path: chart_paths["distributions_path"] = path history = data.get("crux_history", {}) if history and not history.get("error"): path = chart_cwv_timeline(history, charts_dir) if path: chart_paths["timeline_path"] = path if report_type in ("gsc-performance", "full"): gsc = data.get("gsc", data) path = chart_top_queries(gsc, charts_dir) if path: chart_paths["top_queries_path"] = path if report_type in ("indexation", "full"): inspect = data.get("inspection", data) path = chart_index_status(inspect, charts_dir) if path: chart_paths["index_status_path"] = path except RuntimeError as exc: result["error"] = str(exc) return result # ── Build HTML Sections ────────────────────────────────────────────────── sections = [] fig_num = 1 # ── CWV-AUDIT report ───────────────────────────────────────────────────── if report_type == "cwv-audit": mobile = data.get("psi", data) if isinstance(mobile, dict): mobile = mobile.get("psi", {}).get("mobile", mobile) perf_score = mobile.get("lighthouse_scores", {}).get("performance") if isinstance(mobile, dict) else None sections.append(_build_title_page( domain, "Core Web Vitals Audit", "Performance & User Experience Analysis", score=perf_score, score_label="Lighthouse Performance Score", meta_items=[timestamp, "PageSpeed Insights + CrUX"], )) # TOC toc_sections = [ {"num": 1, "title": "Executive Summary", "subs": [ "Key Metrics & Critical Issues", ]}, {"num": 2, "title": "Core Web Vitals & Performance", "score": perf_score, "subs": [ "Lighthouse Scores", "Lab Metrics", "CrUX Field Data", "Failed Audits & SEO Checks", ]}, {"num": 3, "title": "Recommendations", "subs": [ "Prioritized Action Items", ]}, {"num": 4, "title": "Data Sources & Methodology", "subs": []}, ] sections.append(_build_toc(toc_sections)) sections.append(_build_executive_summary(domain, timestamp, data, report_type)) cwv_html, fig_num = _build_cwv_section( data, data.get("crux", {}), chart_paths, data.get("crux_history"), section_num=2, ) sections.append(cwv_html) sections.append(_build_recommendations(data, section_num=3)) sections.append(_build_methodology_footer(domain, timestamp)) # ── GSC-PERFORMANCE report ─────────────────────────────────────────────── elif report_type == "gsc-performance": gsc = data.get("gsc", data) clicks = gsc.get("totals", {}).get("clicks", 0) sections.append(_build_title_page( domain, "Search Console Performance", "Google Search Analytics Report", score=f"{clicks:,}", score_label="Total Clicks", meta_items=[timestamp, "Google Search Console API"], )) toc_sections = [ {"num": 1, "title": "Executive Summary", "subs": [ "Key Metrics & Quick Wins", ]}, {"num": 2, "title": "Search Console Performance", "subs": [ "Key Metrics", "Top Queries by Clicks", "Query Detail Table", "Position Analysis & Quick Wins", ]}, {"num": 3, "title": "Recommendations", "subs": [ "Prioritized Action Items", ]}, {"num": 4, "title": "Data Sources & Methodology", "subs": []}, ] sections.append(_build_toc(toc_sections)) sections.append(_build_executive_summary(domain, timestamp, data, report_type)) gsc_html, fig_num = _build_gsc_section(gsc, chart_paths, section_num=2) sections.append(gsc_html) sections.append(_build_recommendations(data, section_num=3)) sections.append(_build_methodology_footer(domain, timestamp, _gsc_anomaly_warning(gsc))) # ── INDEXATION report ──────────────────────────────────────────────────── elif report_type == "indexation": inspect = data.get("inspection", data) total = inspect.get("total", 0) sections.append(_build_title_page( domain, "Indexation Status Report", "URL Index Coverage Analysis", score=total, score_label="URLs Inspected", meta_items=[timestamp, "URL Inspection API"], )) toc_sections = [ {"num": 1, "title": "Executive Summary", "subs": [ "Index Coverage Overview", ]}, {"num": 2, "title": "Indexation Status", "subs": [ "Index Coverage Overview", "Per-URL Results", ]}, {"num": 3, "title": "Recommendations", "subs": [ "Prioritized Action Items", ]}, {"num": 4, "title": "Data Sources & Methodology", "subs": []}, ] sections.append(_build_toc(toc_sections)) sections.append(_build_executive_summary(domain, timestamp, data, report_type)) idx_html, fig_num = _build_indexation_section(inspect, chart_paths, section_num=2) sections.append(idx_html) sections.append(_build_recommendations(data, section_num=3)) sections.append(_build_methodology_footer(domain, timestamp)) # ── FULL report ────────────────────────────────────────────────────────── elif report_type == "full": psi = data.get("psi", {}) mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {} perf_score = mobile.get("lighthouse_scores", {}).get("performance") if isinstance(mobile, dict) else None summary = data.get("summary", {}) if isinstance(data.get("summary"), dict) else {} health_score = summary.get("health_score") display_score = health_score if health_score is not None else perf_score has_audit_schema = bool(summary or data.get("categories") or data.get("action_plan")) sections.append(_build_title_page( domain, "Full SEO Audit Report" if has_audit_schema else "Google SEO Intelligence Report", "Comprehensive Analysis", score=display_score, score_label="SEO Health Score" if health_score is not None else ("Lighthouse Performance Score" if perf_score else None), meta_items=[timestamp, "Full Audit"], )) # Build TOC dynamically based on available data toc_sections = [ {"num": 1, "title": "Executive Summary", "subs": [ "Key Metrics, Critical Issues & Quick Wins", ]}, ] sec_num = 2 if data.get("categories"): toc_sections.append({ "num": sec_num, "title": "Audit Categories", "subs": [ "What Works", "Findings by Severity", ], }) sec_num += 1 if data.get("psi") or data.get("crux"): toc_sections.append({ "num": sec_num, "title": "Core Web Vitals & Performance", "score": perf_score, "subs": [ "Lighthouse Scores & Lab Metrics", "CrUX Field Data & Trends", "Failed Audits & Opportunities", ], }) sec_num += 1 if data.get("gsc"): toc_sections.append({ "num": sec_num, "title": "Search Console Performance", "subs": [ "Key Metrics & Top Queries", "Position Analysis & Quick Wins", ], }) sec_num += 1 if data.get("inspection"): toc_sections.append({ "num": sec_num, "title": "Indexation Status", "subs": [ "Index Coverage & Per-URL Results", ], }) sec_num += 1 if data.get("action_plan"): toc_sections.append({ "num": sec_num, "title": "Action Plan", "subs": [ "Phased Roadmap", ], }) else: toc_sections.append({ "num": sec_num, "title": "Recommendations", "subs": [ "Prioritized Action Items", ], }) rec_num = sec_num sec_num += 1 toc_sections.append({ "num": sec_num, "title": "Data Sources & Methodology", "subs": [], }) sections.append(_build_toc(toc_sections)) sections.append(_build_executive_summary(domain, timestamp, data, report_type)) current_sec = 2 if data.get("categories"): sections.append(_build_full_audit_categories(data, section_num=current_sec)) current_sec += 1 if data.get("psi") or data.get("crux"): cwv_html, fig_num = _build_cwv_section( data.get("psi", {}), data.get("crux", {}), chart_paths, data.get("crux_history"), section_num=current_sec, ) sections.append(cwv_html) current_sec += 1 if data.get("gsc"): gsc_html, fig_num = _build_gsc_section( data["gsc"], chart_paths, section_num=current_sec, fig_start=fig_num, ) sections.append(gsc_html) current_sec += 1 if data.get("inspection"): idx_html, fig_num = _build_indexation_section( data["inspection"], chart_paths, section_num=current_sec, fig_start=fig_num, ) sections.append(idx_html) current_sec += 1 action_html = _build_audit_action_plan(data, section_num=rec_num) if action_html: sections.append(action_html) else: sections.append(_build_recommendations(data, section_num=rec_num)) sections.append(_build_methodology_footer(domain, timestamp, _gsc_anomaly_warning(data.get("gsc", {})))) # ── Assemble Final HTML ────────────────────────────────────────────────── css = _build_css(domain) body = "\n".join(sections) html_content = ( f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'\n' f'{body}\n' f'\n' f'\n' ) # ── Write Output Files ─────────────────────────────────────────────────── safe_domain = domain.replace(":", "_").replace("/", "_") base_name = f"Google-SEO-Report-{safe_domain}-{report_type}" if output_format in ("html", "both", "all"): html_path = output_dir / f"{base_name}.html" with open(html_path, "w", encoding="utf-8") as f: f.write(html_content) result["files"].append(str(html_path)) if output_format in ("pdf", "both", "all"): if HTML is None: result["error"] = ( "weasyprint is required for PDF generation. " "Install the report dependencies from requirements.txt." ) return result pdf_path = output_dir / f"{base_name}.pdf" try: HTML(string=html_content).write_pdf(str(pdf_path)) result["files"].append(str(pdf_path)) # Post-generation review review = _review_pdf(str(pdf_path), html_content) if review: result["review"] = review except Exception as e: result["error"] = f"PDF generation failed: {e}" if output_format in ("xlsx", "all"): xlsx_path = generate_xlsx(data, domain, report_type, output_dir) if xlsx_path: result["files"].append(xlsx_path) return result def _review_pdf(pdf_path: str, html_content: str) -> dict: """ RULE: Always review the PDF before presenting to the user. Check for common rendering issues. """ review = {"issues": [], "page_count": None, "file_size_kb": None} # File size try: size = os.path.getsize(pdf_path) review["file_size_kb"] = round(size / 1024, 1) except OSError: pass # Page count (if pypdf available) try: from pypdf import PdfReader reader = PdfReader(pdf_path) review["page_count"] = len(reader.pages) except ImportError: review["issues"].append("pypdf missing, page-count check skipped") # HTML-level checks import re # Check for empty chart containers (img with no src) empty_imgs = re.findall(r']*src=""[^>]*>', html_content) if empty_imgs: review["issues"].append(f"{len(empty_imgs)} empty image tag(s) found") # Check for very short sections (might appear as mostly whitespace) sections = html_content.split('div class="section"') for i, sec in enumerate(sections[1:], 1): # Strip HTML tags to get text content text_only = re.sub(r'<[^>]+>', '', sec[:2000]) text_only = re.sub(r'\s+', ' ', text_only).strip() if len(text_only) < 50: review["issues"].append(f"Section {i} has very little text content ({len(text_only)} chars)") # Check for duplicate content tables = re.findall(r'.*?
    ', html_content, re.DOTALL) if len(tables) != len(set(tables)): review["issues"].append("Duplicate tables detected") if not review["issues"]: review["status"] = "PASS" else: review["status"] = f"WARN ({len(review['issues'])} issues)" return review # ─── XLSX Export ────────────────────────────────────────────────────────────── def generate_xlsx(data, domain, report_type, output_dir): """Generate Excel workbook from audit data. Returns path to generated .xlsx file, or None if openpyxl unavailable. """ try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter except ImportError: print("Warning: openpyxl not installed. Skipping xlsx. Install: pip install openpyxl", file=sys.stderr) return None wb = Workbook() output_dir = Path(output_dir) # Brand colors for Excel navy_fill = PatternFill(start_color="1E3A5F", end_color="1E3A5F", fill_type="solid") cream_fill = PatternFill(start_color="FAF9F7", end_color="FAF9F7", fill_type="solid") green_fill = PatternFill(start_color="D4EDDA", end_color="D4EDDA", fill_type="solid") amber_fill = PatternFill(start_color="FFF3CD", end_color="FFF3CD", fill_type="solid") red_fill = PatternFill(start_color="F8D7DA", end_color="F8D7DA", fill_type="solid") header_font = Font(name="Calibri", bold=True, color="FFFFFF", size=11) body_font = Font(name="Calibri", size=10) thin_border = Border( left=Side(style="thin", color="D6D3CC"), right=Side(style="thin", color="D6D3CC"), top=Side(style="thin", color="D6D3CC"), bottom=Side(style="thin", color="D6D3CC"), ) def _style_header(ws, row=1): """Apply navy header styling to the first row.""" for cell in ws[row]: cell.font = header_font cell.fill = navy_fill cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = thin_border def _auto_width(ws): """Auto-fit column widths based on content.""" for col_cells in ws.columns: max_len = 0 col_letter = get_column_letter(col_cells[0].column) for cell in col_cells: if cell.value: max_len = max(max_len, len(str(cell.value))) ws.column_dimensions[col_letter].width = min(max_len + 4, 60) def _severity_fill(severity): """Return fill color based on severity string.""" s = str(severity).lower() if s in ("critical", "fail", "high"): return red_fill if s in ("warning", "warn", "medium"): return amber_fill if s in ("pass", "good", "low"): return green_fill return cream_fill # ── Summary Sheet ───────────────────────────────────────────────────────── ws = wb.active ws.title = "Summary" ws.append(["Google SEO Report", "", ""]) ws.append(["Domain", domain]) ws.append(["Report Type", report_type]) ws.append(["Generated", datetime.now().strftime("%Y-%m-%d %H:%M")]) ws.append([]) # Add scores if available if report_type in ("cwv-audit", "full"): psi = data.get("psi", data) mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {} scores = mobile.get("lighthouse_scores", {}) if isinstance(mobile, dict) else {} if scores: ws.append(["Lighthouse Scores", ""]) ws.append(["Category", "Score"]) _style_header(ws, ws.max_row) for cat in ["performance", "accessibility", "best_practices", "seo"]: val = scores.get(cat) if val is not None: row_num = ws.max_row + 1 ws.append([cat.replace("_", " ").title(), int(val * 100) if val <= 1 else val]) ws.cell(row=row_num, column=2).fill = _severity_fill( "pass" if (val * 100 if val <= 1 else val) >= 90 else "warning" if (val * 100 if val <= 1 else val) >= 50 else "fail" ) ws.append([]) # CWV metrics if report_type in ("cwv-audit", "full"): crux = data.get("crux", {}) metrics = crux.get("metrics", {}) if isinstance(crux, dict) else {} if metrics: ws.append(["Core Web Vitals (Field Data)", ""]) ws.append(["Metric", "Value", "Rating"]) _style_header(ws, ws.max_row) for metric_name, metric_data in metrics.items(): if isinstance(metric_data, dict): p75 = metric_data.get("percentile_p75", metric_data.get("p75", "")) rating = metric_data.get("category", "") ws.append([metric_name, p75, rating]) ws.append([]) _auto_width(ws) # ── GSC Queries Sheet ───────────────────────────────────────────────────── gsc = data.get("gsc", {}) queries = gsc.get("queries", gsc.get("rows", [])) if queries and isinstance(queries, list): ws2 = wb.create_sheet("Queries") ws2.append(["Query", "Clicks", "Impressions", "CTR", "Position"]) _style_header(ws2) for row_data in queries[:500]: if isinstance(row_data, dict): keys = row_data.get("keys", []) query = keys[0] if keys else row_data.get("query", "") ws2.append([ query, row_data.get("clicks", 0), row_data.get("impressions", 0), f"{row_data.get('ctr', 0):.2%}" if isinstance(row_data.get("ctr"), (int, float)) else str(row_data.get("ctr", "")), round(row_data.get("position", 0), 1) if isinstance(row_data.get("position"), (int, float)) else row_data.get("position", ""), ]) ws2.auto_filter.ref = f"A1:E{ws2.max_row}" ws2.freeze_panes = "A2" _auto_width(ws2) # ── GSC Pages Sheet ─────────────────────────────────────────────────────── pages = gsc.get("pages", []) if pages and isinstance(pages, list): ws3 = wb.create_sheet("Pages") ws3.append(["Page", "Clicks", "Impressions", "CTR", "Position"]) _style_header(ws3) for row_data in pages[:500]: if isinstance(row_data, dict): keys = row_data.get("keys", []) page = keys[0] if keys else row_data.get("page", "") ws3.append([ page, row_data.get("clicks", 0), row_data.get("impressions", 0), f"{row_data.get('ctr', 0):.2%}" if isinstance(row_data.get("ctr"), (int, float)) else str(row_data.get("ctr", "")), round(row_data.get("position", 0), 1) if isinstance(row_data.get("position"), (int, float)) else row_data.get("position", ""), ]) ws3.auto_filter.ref = f"A1:E{ws3.max_row}" ws3.freeze_panes = "A2" _auto_width(ws3) # ── Indexation Sheet ────────────────────────────────────────────────────── inspection = data.get("inspection", {}) results = inspection.get("results", []) if results and isinstance(results, list): ws4 = wb.create_sheet("Indexation") ws4.append(["URL", "Verdict", "Coverage State", "Indexing State", "Crawled As", "Last Crawl"]) _style_header(ws4) for item in results[:500]: if isinstance(item, dict): result_data = item.get("inspectionResult", item) idx = result_data.get("indexStatusResult", {}) ws4.append([ item.get("url", result_data.get("inspectedUrl", "")), idx.get("verdict", ""), idx.get("coverageState", ""), idx.get("indexingState", ""), idx.get("crawledAs", ""), idx.get("lastCrawlTime", ""), ]) ws4.auto_filter.ref = f"A1:F{ws4.max_row}" ws4.freeze_panes = "A2" _auto_width(ws4) # ── Save ────────────────────────────────────────────────────────────────── timestamp = datetime.now().strftime("%Y%m%d-%H%M") filename = f"Google-SEO-Report-{domain}-{timestamp}.xlsx" filepath = output_dir / filename wb.save(filepath) return str(filepath) # ─── CLI ───────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Google SEO Report Generator - Professional PDF/HTML reports" ) parser.add_argument( "--type", "-t", choices=["cwv-audit", "gsc-performance", "indexation", "full"], required=True, help="Report type", ) parser.add_argument("--data", "-d", help="Path to JSON data file (or pipe via stdin)") parser.add_argument("--domain", required=True, help="Domain name for the report header") parser.add_argument("--output-dir", "-o", default=".", help="Output directory (default: current)") parser.add_argument( "--format", "-f", choices=["pdf", "html", "xlsx", "both", "all"], default="pdf", help="Output format: pdf, html, xlsx, both (pdf+html), all (pdf+html+xlsx)", ) parser.add_argument("--json", "-j", action="store_true", help="Output metadata as JSON") args = parser.parse_args() # Load data if args.data: try: with open(args.data, "r", encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, IOError) as e: print(f"Error reading data file: {e}", file=sys.stderr) sys.exit(1) elif not sys.stdin.isatty(): try: data = json.load(sys.stdin) except json.JSONDecodeError as e: print(f"Error parsing stdin JSON: {e}", file=sys.stderr) sys.exit(1) else: print("Error: Provide --data file or pipe JSON via stdin.", file=sys.stderr) sys.exit(1) result = generate_report( report_type=args.type, data=data, domain=args.domain, output_dir=args.output_dir, output_format=args.format, ) if result.get("error"): print(f"Error: {result['error']}", file=sys.stderr) if args.json: print(json.dumps(result, indent=2)) else: for f in result.get("files", []): print(f"Generated: {f}") if __name__ == "__main__": main()