Spaces:
Running
Running
Download app/gsc.py from codey-lab/Universal-AI-Hub: direct link, hf CLI and curl.
- Browser
- Download file 5.98 kB
-
https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/gsc.py
- Command line
-
hf download hf://spaces/codey-lab/Universal-AI-Hub/app/gsc.py
-
curl -L -o gsc.py https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/gsc.py
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 | |