Spaces:
Running
Running
File size: 5,976 Bytes
5ef3828 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | # =============================================================================
# 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
|