Alibrown's picture
Upload 58 files
5ef3828 verified
Raw History Blame Contribute Delete
5.98 kB
# =============================================================================
# app/gsc.py
# Google Search Console tool — top queries / pages for a property
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
# Copyright 2026 - Volkan Kücükbudak
# Apache License V. 2 + ESOL 1.1
# (idea: google-search-console.py)
# =============================================================================
# ARCHITECTURE NOTE:
# Lives in app/* only. Config from app/.pyfun [GSC]; the credentials come from the
# ENV var NAMED there (GSC_ENV_KEY) — never from .pyfun, never hard-coded.
#
# OPTIONAL dependencies (imported lazily — the hub starts fine without them):
# pip install google-api-python-client google-auth
#
# Credentials ENV value = the service-account JSON text itself (ideal for HF Space
# secrets) OR a path to the JSON file. The service account's e-mail must be added as
# a user in Search Console for the property. Scope is READ-ONLY.
#
# The Google client is blocking → it runs in a worker thread (asyncio.to_thread),
# so it never freezes the event loop.
#
# STATUS: shipped INACTIVE (GSC_ACTIVE = "false" in .pyfun).
# =============================================================================
import asyncio
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
from . import config
logger = logging.getLogger("gsc")
_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
_DIMENSIONS = {"query", "page", "country", "device", "date"}
_creds_value = "" # ENV value (JSON text or file path) — NEVER logged
_active = False
def initialize() -> None:
"""Read [GSC]. Inactive / no credentials → stays inactive. No crash."""
global _creds_value, _active
cfg = config.get_gsc()
if not cfg or str(cfg.get("GSC_ACTIVE", "false")).lower() != "true":
logger.info("GSC not active in .pyfun [GSC] — skipped")
return
env_key = cfg.get("GSC_ENV_KEY", "GSC_CREDENTIALS")
_creds_value = (os.getenv(env_key, "") if env_key else "").strip()
if not _creds_value:
logger.warning("GSC: %s not set in .env — tool disabled", env_key)
return
_active = True
logger.info("GSC initialized (read-only, default property: %s)", cfg.get("GSC_DEFAULT_PROPERTY") or "none")
def is_ready() -> bool:
return _active
def _build_service():
"""Blocking: credentials + API client. Lazy imports → clear error if libs are missing."""
try:
from google.oauth2 import service_account
from googleapiclient.discovery import build
except ImportError:
raise RuntimeError("Search Console libs missing — pip install google-api-python-client google-auth")
if _creds_value.startswith("{"):
creds = service_account.Credentials.from_service_account_info(json.loads(_creds_value), scopes=[_SCOPE])
else:
creds = service_account.Credentials.from_service_account_file(_creds_value, scopes=[_SCOPE])
cfg = config.get_gsc()
return build(cfg.get("GSC_API_NAME", "searchconsole"), cfg.get("GSC_API_VERSION", "v1"),
credentials=creds, cache_discovery=False)
def _query_sync(property_url: str, days_back: int, row_limit: int, dimension: str) -> List[Dict[str, Any]]:
service = _build_service()
# GSC data lags ~2-3 days
end = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d")
start = (datetime.now(timezone.utc) - timedelta(days=days_back + 2)).strftime("%Y-%m-%d")
body = {"startDate": start, "endDate": end, "dimensions": [dimension], "rowLimit": row_limit}
resp = service.searchanalytics().query(siteUrl=property_url, body=body).execute()
label = "keyword" if dimension == "query" else dimension
return [
{
label: row["keys"][0],
"clicks": row.get("clicks", 0),
"impressions": row.get("impressions", 0),
"ctr": round(row.get("ctr", 0) * 100, 2), # in %
"position": round(row.get("position", 0), 1),
}
for row in resp.get("rows", [])
]
async def query(
property_url: str = "",
days_back: int = 30,
row_limit: int = 10,
dimension: str = "query",
) -> List[Dict[str, Any]]:
"""
Top queries (or pages/countries/devices/dates) of a Search Console property.
Args:
property_url: 'sc-domain:example.com' or 'https://example.com/'. Empty → GSC_DEFAULT_PROPERTY.
days_back: 1-365 (default 30).
row_limit: 1-100 (default 10).
dimension: query | page | country | device | date.
Raises:
ValueError — bad input.
RuntimeError — inactive / libs missing / API error (sanitized, no secrets).
"""
if not _active:
raise RuntimeError("GSC not active — set GSC_ACTIVE = \"true\" in .pyfun and the credentials in .env")
prop = (property_url or config.get_gsc().get("GSC_DEFAULT_PROPERTY", "")).strip()
if not (prop.startswith("sc-domain:") or prop.startswith(("http://", "https://"))):
raise ValueError("property_url must be 'sc-domain:example.com' or a full https:// URL")
dimension = str(dimension or "query").lower()
if dimension not in _DIMENSIONS:
raise ValueError(f"dimension must be one of {sorted(_DIMENSIONS)}")
days_back = max(1, min(int(days_back or 30), 365))
row_limit = max(1, min(int(row_limit or 10), 100))
try:
return await asyncio.to_thread(_query_sync, prop, days_back, row_limit, dimension)
except RuntimeError:
raise
except Exception as e:
status = getattr(getattr(e, "resp", None), "status", None)
logger.warning("GSC request failed: %s%s", type(e).__name__, f" (HTTP {status})" if status else "")
raise RuntimeError(f"Search Console request failed: {type(e).__name__}{f' (HTTP {status})' if status else ''}") from None