gsc_audit
Run a single command to generate a complete HTML SEO audit report from Google Search Console data, including performance analysis, issue detection, and actionable recommendations.
Instructions
Generate a complete HTML SEO audit report for a Search Console property.
Runs multiple queries (overview, previous-period comparison, top queries, top pages, devices, countries, daily trend, sitemaps, indexing check), detects common issues, builds an actionable strategy and renders everything in a self-contained HTML report with Chart.js graphs. The report layout and colors can be customized via branding.json.
IMPORTANT: If the user has not specified a date range, ask them before calling this tool. Do not assume defaults.
Args: site_url: Site URL (e.g. "https://example.com/" or "sc-domain:example.com"). date_from: Start date (YYYY-MM-DD). date_to: End date (YYYY-MM-DD). output_dir: Directory where to save the HTML report. Defaults to ~/gsc-reports/. branding_path: Optional path to a custom branding.json overriding the default one.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| site_url | Yes | ||
| date_from | Yes | ||
| date_to | Yes | ||
| output_dir | No | ||
| branding_path | No |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| result | Yes |
Implementation Reference
- MCP tool registration and handler for gsc_audit. Decorated with @mcp.tool() and defines the public API (site_url, date_from, date_to, output_dir, branding_path). Delegates the actual work to generate_audit() in audit.py.
@mcp.tool() def gsc_audit( site_url: str, date_from: str, date_to: str, output_dir: str = "", branding_path: str = "", ) -> str: """Generate a complete HTML SEO audit report for a Search Console property. Runs multiple queries (overview, previous-period comparison, top queries, top pages, devices, countries, daily trend, sitemaps, indexing check), detects common issues, builds an actionable strategy and renders everything in a self-contained HTML report with Chart.js graphs. The report layout and colors can be customized via branding.json. IMPORTANT: If the user has not specified a date range, ask them before calling this tool. Do not assume defaults. Args: site_url: Site URL (e.g. "https://example.com/" or "sc-domain:example.com"). date_from: Start date (YYYY-MM-DD). date_to: End date (YYYY-MM-DD). output_dir: Directory where to save the HTML report. Defaults to ~/gsc-reports/. branding_path: Optional path to a custom branding.json overriding the default one. """ path = generate_audit(_api_get, _api_post, site_url, date_from, date_to, output_dir, branding_path) return json.dumps({"report_path": path, "site_url": site_url, "date_from": date_from, "date_to": date_to}) - The core orchestration function that collects data, detects issues, builds strategy, generates HTML, and writes the report file.
def generate_audit( api_get, api_post, site_url: str, date_from: str, date_to: str, output_dir: str = "", branding_path: str = "", ) -> str: data = collect_data(api_get, api_post, site_url, date_from, date_to) issues = detect_issues(data) strategy = build_strategy(data, issues) branding = load_branding(branding_path) html_content = render_html(data, issues, strategy, branding) out_dir = Path(output_dir).expanduser() if output_dir else Path.home() / "gsc-reports" out_dir.mkdir(parents=True, exist_ok=True) filename = f"{site_slug(site_url)}_{date_from}_{date_to}.html" out_path = out_dir / filename out_path.write_text(html_content, encoding="utf-8") return str(out_path) - Data collection helper: queries GSC API for overview, previous period comparison, top queries, top pages, devices, countries, daily trend, sitemaps, and indexing status.
def collect_data(api_get, api_post, site_url: str, date_from: str, date_to: str) -> dict: prev_from, prev_to = previous_period(date_from, date_to) current = _overview(api_post, site_url, date_from, date_to) previous = _overview(api_post, site_url, prev_from, prev_to) top_queries = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["query"], "rowLimit": 50, }) top_pages = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["page"], "rowLimit": 50, }) devices = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["device"], }) countries = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["country"], "rowLimit": 15, }) trend = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["date"], "rowLimit": 1000, }) query_page = _query(api_post, site_url, { "startDate": date_from, "endDate": date_to, "dimensions": ["query", "page"], "rowLimit": 500, }) encoded = urllib.parse.quote(site_url, safe="") try: sitemaps_data = api_get(f"{BASE}/sites/{encoded}/sitemaps") sitemaps = sitemaps_data.get("sitemap", []) except Exception: sitemaps = [] indexing = [] for row in top_pages[:10]: page_url = row["keys"][0] try: data = api_post( f"{INSPECT_BASE}/urlInspection/index:inspect", {"inspectionUrl": page_url, "siteUrl": site_url}, ) result = data.get("inspectionResult", {}).get("indexStatusResult", {}) indexing.append({ "url": page_url, "verdict": result.get("verdict", "UNKNOWN"), "coverageState": result.get("coverageState", ""), }) except Exception as e: indexing.append({"url": page_url, "verdict": "ERROR", "coverageState": str(e)[:100]}) # Flatten row records: copy `clicks`, `impressions`, `ctr`, `position` on top level # so templates can access q.clicks without .get() def _flatten(rows): for r in rows: r.setdefault("clicks", 0) r.setdefault("impressions", 0) r.setdefault("ctr", 0) r.setdefault("position", 0) return rows return { "site_url": site_url, "date_from": date_from, "date_to": date_to, "prev_from": prev_from, "prev_to": prev_to, "current": current, "previous": previous, "top_queries": _flatten(top_queries), "top_pages": _flatten(top_pages), "devices": _flatten(devices), "countries": _flatten(countries), "trend": _flatten(trend), "query_page": _flatten(query_page), "sitemaps": sitemaps, "indexing": indexing, } - Issue detection engine: identifies paginated pages, HTTP/HTTPS mix, multiple hosts, low CTR queries, page-2 opportunities, weak high-visibility pages, sitemap issues, indexing problems, and traffic drops.
def detect_issues(data: dict) -> list[dict]: issues = [] # --- Paginated pages ------------------------------------------------------ paginated = [p for p in data["top_pages"] if "?p=" in p["keys"][0] or "&p=" in p["keys"][0]] if paginated: total_clicks = sum(p.get("clicks", 0) for p in paginated) examples = [ {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"} for p in sorted(paginated, key=lambda x: x.get("clicks", 0), reverse=True)[:10] ] issues.append({ "severity": "high", "category": "Indicizzazione", "title": f"{len(paginated)} pagine paginate indicizzate", "description": f"Le pagine paginate generano {int(total_clicks)} click complessivi e cannibalizzano le pagine principali della categoria. Google può scegliere canonicale diverso da quello dichiarato.", "examples": {"type": "pages", "rows": examples}, "strategy": [ "Aggiungere <link rel=\"canonical\"> su tutte le pagine paginate puntando alla prima pagina della categoria", "Verificare in GSC con URL Inspection che Google rispetti il canonical dichiarato", "In alternativa, valutare infinite scroll con history.pushState che mantiene l'URL principale", "Controllare che le paginate non siano linkate dalla sitemap XML", "Se le pagine 2+ hanno contenuti unici necessari per SEO, considerare una strategia rel=prev/next oppure URL SEO-friendly con titoli differenziati", ], }) # --- HTTP version --------------------------------------------------------- http_pages = [p for p in data["top_pages"] if p["keys"][0].startswith("http://")] if http_pages: total_clicks = sum(p.get("clicks", 0) for p in http_pages) examples = [ {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"} for p in sorted(http_pages, key=lambda x: x.get("clicks", 0), reverse=True)[:10] ] issues.append({ "severity": "high", "category": "Tecnico", "title": "Versione HTTP ancora indicizzata", "description": f"Rilevate {len(http_pages)} pagine HTTP con {int(total_clicks)} click nel periodo. La coesistenza di versione HTTP e HTTPS in indice diluisce i segnali SEO e può generare contenuti duplicati.", "examples": {"type": "pages", "rows": examples}, "strategy": [ "Configurare redirect 301 permanente da http:// a https:// a livello server (Apache/Nginx/CDN)", "Verificare che HSTS sia attivo con max-age di almeno un anno", "Aggiornare la sitemap XML con solo URL HTTPS e risottometterla", "Verificare con curl -I che ogni URL HTTP risponda 301 e non 302", "Richiedere la reindicizzazione delle versioni HTTPS tramite URL Inspection", ], }) # --- Host mix ------------------------------------------------------------- hostnames = set() for p in data["top_pages"]: url = p["keys"][0] if "://" in url: hostnames.add(url.split("://")[1].split("/")[0]) base_hosts = set(h.replace("www.", "") for h in hostnames) if len(hostnames) > len(base_hosts): mixed_pages: dict = {} for p in data["top_pages"]: url = p["keys"][0] if "://" in url: host = url.split("://")[1].split("/")[0] mixed_pages[host] = mixed_pages.get(host, 0) + int(p.get("clicks", 0)) examples_rows = [{"host": h, "clicks": c} for h, c in sorted(mixed_pages.items(), key=lambda x: -x[1])] issues.append({ "severity": "medium", "category": "Tecnico", "title": "Host multipli indicizzati (www e non-www)", "description": f"Rilevati {len(hostnames)} hostname diversi che generano traffico: {', '.join(sorted(hostnames))}. Senza una versione canonica unica, i backlink e i segnali di autorità vengono distribuiti.", "examples": {"type": "hosts", "rows": examples_rows}, "strategy": [ "Scegliere una versione canonica (raccomandato: con www) e impostare redirect 301 da tutte le altre", "Aggiornare il canonical tag di ogni pagina verso l'hostname scelto", "Aggiornare tutti i link interni per usare l'hostname canonico", "Verificare la proprietà canonica in Google Search Console e richiedere la reindicizzazione", ], }) # --- Low CTR on high-volume queries -------------------------------------- low_ctr = [ q for q in data["top_queries"] if q.get("impressions", 0) >= 2000 and q.get("ctr", 0) < 0.02 and q.get("position", 100) <= 10 ] if low_ctr: examples = [ {"query": q["keys"][0], "impressions": int(q.get("impressions", 0)), "ctr": f"{q.get('ctr', 0) * 100:.2f}%", "position": f"{q.get('position', 0):.1f}"} for q in sorted(low_ctr, key=lambda x: x.get("impressions", 0), reverse=True)[:10] ] issues.append({ "severity": "medium", "category": "Contenuti", "title": f"{len(low_ctr)} query con CTR <2% in top 10", "description": "Query posizionate in prima pagina ma con click-through rate molto basso: il posizionamento c'è ma lo snippet non convince l'utente. Ottima opportunità a basso sforzo.", "examples": {"type": "queries", "rows": examples}, "strategy": [ "Riscrivere title tag inserendo USP differenzianti (prezzo, spedizione gratuita, made in Italy, non-iron)", "Aggiornare meta description con call-to-action chiare e limite 155 caratteri", "Implementare schema markup Product/Review/Offer per ottenere rich snippet (stelle, prezzo)", "Testare varianti di title con date/anno per creare percezione di freschezza", "Evitare title generici tipo \"Categoria | Brand\" e preferire formulazioni orientate al beneficio", "Monitorare CTR settimanale dopo ogni modifica e iterare", ], }) # --- Page 2 opportunities ------------------------------------------------ page_two = [q for q in data["top_queries"] if 10 < q.get("position", 0) <= 20 and q.get("impressions", 0) >= 500] if page_two: examples = [ {"query": q["keys"][0], "impressions": int(q.get("impressions", 0)), "ctr": f"{q.get('ctr', 0) * 100:.2f}%", "position": f"{q.get('position', 0):.1f}"} for q in sorted(page_two, key=lambda x: x.get("impressions", 0), reverse=True)[:10] ] issues.append({ "severity": "low", "category": "Opportunità", "title": f"{len(page_two)} query in posizione 11-20 con volume", "description": "Query in seconda pagina di Google che, con una spinta mirata, possono entrare in top 10 e aumentare significativamente il traffico.", "examples": {"type": "queries", "rows": examples}, "strategy": [ "Identificare la pagina che Google posiziona per ognuna di queste query", "Arricchire il contenuto della pagina con informazioni mancanti (FAQ, specifiche, comparazioni)", "Aggiungere link interni dalle pagine ad alta autorità verso queste pagine target", "Analizzare i top 10 risultati attuali per capire cosa li rende migliori e colmare il gap", "Verificare che le keyword siano presenti in H1, H2, URL e nei primi 100 parole", "Considerare backlink da publisher di settore se la competizione è alta", ], }) # --- Weak high-visibility pages ------------------------------------------ weak_pages = [p for p in data["top_pages"] if p.get("impressions", 0) >= 5000 and p.get("ctr", 0) < 0.015] if weak_pages: examples = [ {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"} for p in sorted(weak_pages, key=lambda x: x.get("impressions", 0), reverse=True)[:10] ] issues.append({ "severity": "medium", "category": "Contenuti", "title": f"{len(weak_pages)} pagine ad alta visibilità con CTR <1.5%", "description": "Pagine con tante impression ma pochissimi click. Indicano una discrepanza tra snippet mostrato e intento dell'utente, oppure una posizione troppo bassa.", "examples": {"type": "pages", "rows": examples}, "strategy": [ "Fare una revisione di title e meta description pagina per pagina", "Verificare che il contenuto above-the-fold risponda all'intento di ricerca", "Controllare Core Web Vitals: un LCP alto può penalizzare i click mobile", "Valutare se la pagina è in cannibalizzazione con altre pagine del sito", "Implementare dati strutturati specifici per il tipo di pagina (Product, Article, FAQ)", ], }) # --- Sitemap warnings ---------------------------------------------------- warn_sitemaps = [s for s in data["sitemaps"] if int(s.get("warnings", 0)) > 0 or int(s.get("errors", 0)) > 0] if warn_sitemaps: total_warnings = sum(int(s.get("warnings", 0)) for s in warn_sitemaps) total_errors = sum(int(s.get("errors", 0)) for s in warn_sitemaps) examples_rows = [ {"path": s.get("path", ""), "warnings": int(s.get("warnings", 0)), "errors": int(s.get("errors", 0)), "lastDownloaded": s.get("lastDownloaded", "")[:10]} for s in warn_sitemaps ] issues.append({ "severity": "medium" if total_errors == 0 else "high", "category": "Tecnico", "title": f"Sitemap con {total_warnings} warning e {total_errors} errori", "description": "Le sitemap segnalate da Search Console presentano problemi: URL irraggiungibili, redirect dentro la sitemap, pagine noindex, URL non canonici.", "examples": {"type": "sitemaps", "rows": examples_rows}, "strategy": [ "Aprire Search Console → Sitemap → cliccare su ogni sitemap per leggere il dettaglio dei warning", "Rimuovere dalla sitemap gli URL che restituiscono redirect, 404 o noindex", "Verificare che tutti gli URL in sitemap siano HTTPS, canonici e senza parametri", "Assicurarsi che la sitemap non superi 50MB o 50.000 URL (altrimenti usare sitemap index)", "Risottomettere la sitemap pulita in Search Console", "Automatizzare la generazione della sitemap dal CMS se possibile", ], }) # --- Indexing issues on top pages ---------------------------------------- bad_index = [i for i in data["indexing"] if i["verdict"] not in ("PASS", "UNKNOWN")] if bad_index: examples_rows = [{"url": i["url"], "verdict": i["verdict"], "coverageState": i["coverageState"]} for i in bad_index] issues.append({ "severity": "high", "category": "Indicizzazione", "title": f"{len(bad_index)} pagine top con problemi di indicizzazione", "description": "Pagine tra le più importanti del sito che Google non considera in stato PASS. Ogni pagina qui rappresenta traffico a rischio.", "examples": {"type": "indexing", "rows": examples_rows}, "strategy": [ "Per ogni pagina, aprire Search Console → URL Inspection per leggere il dettaglio", "Se coverage è \"Duplicate, Google chose different canonical\": verificare il canonical dichiarato e il contenuto unico", "Se è \"Crawled - currently not indexed\": migliorare qualità e unicità del contenuto", "Se è \"Discovered - currently not indexed\": problema di crawl budget, ridurre il numero di URL di bassa qualità", "Dopo ogni fix richiedere l'indicizzazione tramite il bottone \"Request indexing\"", "Monitorare lo stato settimanalmente nelle prime 2-3 settimane", ], }) # --- Traffic trend ------------------------------------------------------- cur_clicks = data["current"]["clicks"] prev_clicks = data["previous"]["clicks"] cur_imp = data["current"]["impressions"] prev_imp = data["previous"]["impressions"] if prev_clicks > 0: delta_pct = (cur_clicks - prev_clicks) / prev_clicks * 100 if delta_pct < -10: imp_delta = ((cur_imp - prev_imp) / prev_imp * 100) if prev_imp > 0 else 0 issues.append({ "severity": "high", "category": "Performance", "title": f"Calo click {delta_pct:.1f}% vs periodo precedente", "description": f"Click passati da {int(prev_clicks):,} a {int(cur_clicks):,}. Impression variate del {imp_delta:+.1f}%. Necessaria indagine immediata sulle cause.", "examples": {"type": "metric", "rows": [ {"metric": "Click", "prev": f"{int(prev_clicks):,}", "curr": f"{int(cur_clicks):,}", "delta": f"{delta_pct:+.1f}%"}, {"metric": "Impression", "prev": f"{int(prev_imp):,}", "curr": f"{int(cur_imp):,}", "delta": f"{imp_delta:+.1f}%"}, ]}, "strategy": [ "Confrontare top 20 query e pagine nei due periodi per isolare dove è avvenuto il calo", "Verificare eventuali Google Core Update nelle date del calo", "Controllare modifiche recenti al sito: deploy, redirect, rimozione pagine, cambio template", "Analizzare il log del server per individuare errori 5xx o blocchi del crawler", "Verificare manual action e problemi di sicurezza in Search Console", "Se il calo è su keyword specifiche, analizzare cosa stanno facendo i competitor che ti hanno superato", ], }) severity_order = {"high": 0, "medium": 1, "low": 2} issues.sort(key=lambda x: severity_order.get(x["severity"], 9)) return issues - src/google_search_console_mcp/server.py:14-21 (registration)Instructions registered with FastMCP that tell the LLM to always use gsc_audit for audit/analysis requests and to ask for a date range before calling it.
MCP_INSTRUCTIONS = """Google Search Console MCP Server. When the user asks for an "analysis", "audit", "report", "analisi", "audit" or "report" of a site, ALWAYS use the `gsc_audit` tool to generate a complete HTML report instead of running individual queries manually. IMPORTANT: Before calling `gsc_audit`, you MUST know the date range. If the user did not explicitly specify a period (e.g. "last 30 days", "march 2026", "from X to Y"), ASK the user which period they want to analyze before proceeding. Do not assume a default period. After generating the report, tell the user the file path and optionally open it in the browser. """