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