diff --git a/README.md b/README.md index 14fbda1..c074257 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # analytics -Stateless API proxy for **Google Analytics 4** and **Sklik** (Seznam), running -in AppFactory behind the Caddy reverse proxy at `/apps/analytics`. +Stateless API proxy for **Google Analytics 4**, **Google Search Console**, +**Google Ads** and **Sklik** (Seznam), running in AppFactory behind the Caddy +reverse proxy at `/apps/analytics`. The service stores no secrets. Every credential is supplied **per request** as an `X-` header and used only to call the upstream API. @@ -23,6 +24,10 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a | GA4 Admin | GET | `/ga/admin/accounts`, `/ga/admin/accountSummaries` | | GA4 Admin | GET | `/ga/admin/properties` (`?accountId=`), `/ga/admin/properties/{id}` | | GA4 Admin | GET | `/ga/admin/properties/{id}/dataStreams` | +| Search Console | POST | `/gsc/searchAnalytics/query` (`?siteUrl=`), `/gsc/urlInspection` | +| Search Console | GET | `/gsc/sites`, `/gsc/site`, `/gsc/sitemaps`, `/gsc/sitemap` (`?siteUrl=`) | +| Google Ads | POST | `/googleads/customers/{id}/search`, `/googleads/customers/{id}/searchStream` | +| Google Ads | GET | `/googleads/customers:listAccessibleCustomers` | | Sklik | POST | `/sklik/login`, `/sklik/report/{entity}`, `/sklik/rpc/{method}` | | Sklik | GET | `/sklik/limits` | @@ -36,6 +41,19 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a | `X-GA-Credentials` | one of these | Base64-encoded service-account JSON key; the proxy mints a token. | | `X-GA-Quota-Project` | no | Google Cloud project id for quota/billing. | +**Google Search Console** — same model, prefix `X-GSC-` (scope `webmasters.readonly`): +`X-GSC-Access-Token` / `X-GSC-Credentials` (+ `X-GSC-Quota-Project`). + +**Google Ads** — same OAuth (scope `adwords`) plus a developer token: + +| Header | Required | Meaning | +| --- | --- | --- | +| `X-GAds-Developer-Token` | yes | Google Ads developer token. | +| `X-GAds-Access-Token` | one of these | Ready OAuth2 access token. | +| `X-GAds-Credentials` | one of these | Base64 service-account JSON (needs domain-wide delegation). | +| `X-GAds-Login-Customer-Id` | no | Manager (MCC) id → `login-customer-id`. | +| `X-GAds-Quota-Project` | no | Google Cloud project id. | + **Sklik:** | Header | Required | Meaning | @@ -43,6 +61,9 @@ Interactive docs (Swagger UI): `/docs` — publicly `https://services.csbot.cz/a | `X-Sklik-Token` | yes | Sklik API token from account settings. | | `X-Sklik-User-Id` | no | Managed account id for agency/MCC access. | +Where to obtain each credential is described at the top of `/docs` (Swagger) and +in [documentation/](documentation/). + ## Run locally ```bash @@ -54,7 +75,9 @@ uvicorn app.main:app --reload --port 8000 ## Configuration (env) Non-secret only — see [app/config.py](app/config.py): `ROOT_PATH`, -`GA_DATA_BASE_URL`, `GA_ADMIN_BASE_URL`, `GA_SCOPE`, `SKLIK_BASE_URL`, +`GA_DATA_BASE_URL`, `GA_ADMIN_BASE_URL`, `GA_SCOPE`, `GSC_DATA_BASE_URL`, +`GSC_INSPECT_BASE_URL`, `GSC_SCOPE`, `GOOGLE_ADS_BASE_URL`, +`GOOGLE_ADS_API_VERSION`, `GOOGLE_ADS_SCOPE`, `SKLIK_BASE_URL`, `HTTP_TIMEOUT_SECONDS`, `LOG_LEVEL`. See [documentation/](documentation/) for per-integration detail. diff --git a/app/clients/ga_client.py b/app/clients/google.py similarity index 59% rename from app/clients/ga_client.py rename to app/clients/google.py index a02870c..fa1d208 100644 --- a/app/clients/ga_client.py +++ b/app/clients/google.py @@ -1,15 +1,18 @@ -"""Google Analytics client. +"""Shared Google API client used by Analytics, Search Console and Google Ads. -Thin proxy over the GA4 Data API and Admin API. Request/response bodies are -forwarded as-is so callers keep the full flexibility of Google's API; this -module only handles authentication (Bearer token), the base URL, and error +A single Google OAuth mechanism backs all three services - the only difference +is the OAuth *scope* (carried on ``GoogleCredentials.scope``) and, for Google +Ads, a couple of extra headers. Request/response bodies are forwarded as-is so +callers keep the full upstream API surface; this module only handles +authentication (Bearer token), the optional quota-project header and error mapping. -Authentication (per chosen model, token wins over service account): - * If ``X-GA-Access-Token`` was supplied, it is used directly. - * Otherwise a short-lived access token is minted from the service-account - JSON via google-auth and cached in-memory (keyed by key id + scope) until - shortly before it expires. The key material is never written to disk or log. +Auth (token wins over service account): + * If an access token was supplied in the header, it is used directly. + * Otherwise a short-lived access token is minted from the service-account JSON + via google-auth for the requested scope and cached in-memory (keyed by + key id + email + scope) until shortly before it expires. Key material is + never written to disk or log. """ from __future__ import annotations @@ -22,7 +25,7 @@ import httpx from fastapi.concurrency import run_in_threadpool from .. import config -from ..credentials import GaCredentials +from ..credentials import GoogleCredentials from ..errors import MissingCredentialsError, UpstreamError from ..logging_config import get_logger @@ -50,7 +53,7 @@ def _mint_token_sync(info: dict, scope: str) -> tuple[str, float]: ) except (ValueError, KeyError) as exc: raise MissingCredentialsError( - f"X-GA-Credentials is not a usable service-account key: {exc}" + f"Service-account credentials are not usable: {exc}" ) from exc try: @@ -73,17 +76,17 @@ def _cache_key(info: dict, scope: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() -async def _bearer_token(creds: GaCredentials) -> str: +async def bearer_token(creds: GoogleCredentials) -> str: if creds.access_token: return creds.access_token if creds.service_account_info is None: - # get_ga_credentials guarantees one of the two, but be defensive. + # Credential dependencies guarantee one of the two, but be defensive. raise MissingCredentialsError( - "No GA access token and no service-account credentials available." + "No Google access token and no service-account credentials available." ) - scope = config.GA_SCOPE + scope = creds.scope key = _cache_key(creds.service_account_info, scope) now = time.time() @@ -102,27 +105,38 @@ async def _bearer_token(creds: GaCredentials) -> str: return token -class GoogleAnalyticsClient: - """Authenticated HTTP client for the GA4 Data and Admin APIs.""" +class GoogleApiClient: + """Authenticated HTTP client for any Google REST API. - def __init__(self, creds: GaCredentials) -> None: + ``extra_headers`` lets callers add upstream-specific headers (e.g. the + Google Ads ``developer-token`` / ``login-customer-id``). + """ + + def __init__( + self, + creds: GoogleCredentials, + extra_headers: dict[str, str] | None = None, + *, + service_name: str = "Google", + ) -> None: self._creds = creds + self._extra_headers = extra_headers or {} + self._service_name = service_name - async def _request( + async def request( self, method: str, - base_url: str, - path: str, + url: str, *, params: dict | None = None, json_body: Any | None = None, ) -> Any: - token = await _bearer_token(self._creds) + token = await bearer_token(self._creds) headers = {"Authorization": f"Bearer {token}"} if self._creds.quota_project: headers["x-goog-user-project"] = self._creds.quota_project + headers.update(self._extra_headers) - url = f"{base_url}{path}" try: async with httpx.AsyncClient( timeout=config.HTTP_TIMEOUT_SECONDS @@ -132,34 +146,23 @@ class GoogleAnalyticsClient: ) except httpx.TimeoutException as exc: raise UpstreamError( - "Google Analytics request timed out.", status=504 + f"{self._service_name} request timed out.", status=504 ) from exc except httpx.HTTPError as exc: raise UpstreamError( - f"Google Analytics is unreachable: {exc}", status=502 + f"{self._service_name} is unreachable: {exc}", status=502 ) from exc - return _parse_google_response(resp) + return _parse_google_response(resp, self._service_name) - # --- Data API ------------------------------------------------------------- - async def data_post(self, path: str, body: Any) -> Any: - return await self._request( - "POST", config.GA_DATA_BASE_URL, path, json_body=body - ) + async def get(self, url: str, params: dict | None = None) -> Any: + return await self.request("GET", url, params=params) - async def data_get(self, path: str, params: dict | None = None) -> Any: - return await self._request( - "GET", config.GA_DATA_BASE_URL, path, params=params - ) - - # --- Admin API ------------------------------------------------------------ - async def admin_get(self, path: str, params: dict | None = None) -> Any: - return await self._request( - "GET", config.GA_ADMIN_BASE_URL, path, params=params - ) + async def post(self, url: str, json_body: Any) -> Any: + return await self.request("POST", url, json_body=json_body) -def _parse_google_response(resp: httpx.Response) -> Any: +def _parse_google_response(resp: httpx.Response, service_name: str) -> Any: try: payload = resp.json() except ValueError: @@ -168,10 +171,17 @@ def _parse_google_response(resp: httpx.Response) -> Any: if resp.is_success: return payload - # Google returns {"error": {"code", "message", "status", ...}}. - message = "Google Analytics API error" - if isinstance(payload, dict) and isinstance(payload.get("error"), dict): - message = payload["error"].get("message", message) + # Google returns {"error": {"code", "message", "status", ...}}; Google Ads + # streaming returns a list whose first element may carry the error. + message = f"{service_name} API error" + err_obj: Any = None + if isinstance(payload, dict): + err_obj = payload.get("error") + elif isinstance(payload, list) and payload and isinstance(payload[0], dict): + err_obj = payload[0].get("error") + if isinstance(err_obj, dict) and err_obj.get("message"): + message = err_obj["message"] + raise UpstreamError( message, status=502 if resp.status_code >= 500 else resp.status_code, diff --git a/app/config.py b/app/config.py index 9306d96..51b0e4b 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,30 @@ GA_SCOPE = os.getenv( "GA_SCOPE", "https://www.googleapis.com/auth/analytics.readonly" ) +# --- Google Search Console ---------------------------------------------------- +# Search Analytics, Sites and Sitemaps live under the Webmasters v3 API; the +# newer URL Inspection lives under searchconsole.googleapis.com/v1. +GSC_DATA_BASE_URL = os.getenv( + "GSC_DATA_BASE_URL", "https://www.googleapis.com/webmasters/v3" +) +GSC_INSPECT_BASE_URL = os.getenv( + "GSC_INSPECT_BASE_URL", "https://searchconsole.googleapis.com/v1" +) +GSC_SCOPE = os.getenv( + "GSC_SCOPE", "https://www.googleapis.com/auth/webmasters.readonly" +) + +# --- Google Ads --------------------------------------------------------------- +# Google Ads API versions are deprecated roughly yearly - keep the version in an +# env var so it can be bumped without a code change. +GOOGLE_ADS_BASE_URL = os.getenv( + "GOOGLE_ADS_BASE_URL", "https://googleads.googleapis.com" +) +GOOGLE_ADS_API_VERSION = os.getenv("GOOGLE_ADS_API_VERSION", "v19") +GOOGLE_ADS_SCOPE = os.getenv( + "GOOGLE_ADS_SCOPE", "https://www.googleapis.com/auth/adwords" +) + # --- Sklik (Seznam) ----------------------------------------------------------- # Sklik "Drak" JSON API. The method name is appended to this base URL and the # HTTP body is a JSON array of positional arguments. diff --git a/app/credentials.py b/app/credentials.py index 1cec05b..e0cc966 100644 --- a/app/credentials.py +++ b/app/credentials.py @@ -6,16 +6,17 @@ supplied per request as an X- header and used only to talk to the upstream API ``Header`` parameters makes them appear per-operation in Swagger, including the "Try it out" form. -Google Analytics (chosen model: token has precedence over service account): - * ``X-GA-Access-Token`` - a ready OAuth2 access token; used directly as Bearer. - * ``X-GA-Credentials`` - base64-encoded service-account JSON key; the proxy - mints a short-lived access token from it. - * ``X-GA-Quota-Project`` - optional billing/quota project id. - At least one of token / credentials must be present. +All three Google services share the same OAuth mechanism (token wins over +service account); they differ only in the OAuth *scope* and the header prefix: -Sklik: - * ``X-Sklik-Token`` - the Sklik API token from account settings. The proxy - calls ``client.loginByToken`` to obtain a session. + * Google Analytics -> ``X-GA-*`` (scope analytics.readonly) + * Search Console -> ``X-GSC-*`` (scope webmasters.readonly) + * Google Ads -> ``X-GAds-*`` (scope adwords) + developer token + +Each provides ``*-Access-Token`` (ready Bearer token, takes precedence) and +``*-Credentials`` (base64 service-account JSON; the proxy mints a token). + +Sklik uses ``X-Sklik-Token`` (the proxy calls client.loginByToken). """ from __future__ import annotations @@ -26,17 +27,72 @@ from dataclasses import dataclass from fastapi import Header +from . import config from .errors import MissingCredentialsError -# --- Google Analytics --------------------------------------------------------- +# --- Google (shared) ---------------------------------------------------------- @dataclass -class GaCredentials: +class GoogleCredentials: access_token: str | None service_account_info: dict | None quota_project: str | None + scope: str +@dataclass +class GoogleAdsCredentials: + google: GoogleCredentials + developer_token: str + login_customer_id: str | None + + +def _build_google_credentials( + access_token: str | None, + raw_credentials: str | None, + quota_project: str | None, + scope: str, + *, + token_header: str, + creds_header: str, +) -> GoogleCredentials: + """Parse a Google access token / base64 service-account JSON from headers.""" + token = (access_token or "").strip() or None + + service_account_info: dict | None = None + raw = (raw_credentials or "").strip() + if raw: + try: + decoded = base64.b64decode(raw, validate=True) + except (binascii.Error, ValueError) as exc: + raise MissingCredentialsError( + f"{creds_header} is not valid base64." + ) from exc + try: + service_account_info = json.loads(decoded) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise MissingCredentialsError( + f"{creds_header} does not decode to valid JSON." + ) from exc + if not isinstance(service_account_info, dict): + raise MissingCredentialsError( + f"{creds_header} JSON must be a service-account object." + ) + + if not token and service_account_info is None: + raise MissingCredentialsError( + f"Provide either {token_header} or {creds_header}." + ) + + return GoogleCredentials( + access_token=token, + service_account_info=service_account_info, + quota_project=(quota_project or "").strip() or None, + scope=scope, + ) + + +# --- Google Analytics --------------------------------------------------------- def get_ga_credentials( x_ga_access_token: str | None = Header( default=None, @@ -54,42 +110,101 @@ def get_ga_credentials( x_ga_quota_project: str | None = Header( default=None, alias="X-GA-Quota-Project", - description="Optional Google Cloud project id used for quota/billing " + description="Optional Google Cloud project id for quota/billing " "(sets the x-goog-user-project header upstream).", ), -) -> GaCredentials: - """Resolve GA credentials from headers. Token wins over service account.""" - access_token = (x_ga_access_token or "").strip() or None +) -> GoogleCredentials: + return _build_google_credentials( + x_ga_access_token, + x_ga_credentials, + x_ga_quota_project, + config.GA_SCOPE, + token_header="X-GA-Access-Token", + creds_header="X-GA-Credentials", + ) - service_account_info: dict | None = None - raw = (x_ga_credentials or "").strip() - if raw: - try: - decoded = base64.b64decode(raw, validate=True) - except (binascii.Error, ValueError) as exc: - raise MissingCredentialsError( - "X-GA-Credentials is not valid base64." - ) from exc - try: - service_account_info = json.loads(decoded) - except (json.JSONDecodeError, UnicodeDecodeError) as exc: - raise MissingCredentialsError( - "X-GA-Credentials does not decode to valid JSON." - ) from exc - if not isinstance(service_account_info, dict): - raise MissingCredentialsError( - "X-GA-Credentials JSON must be a service-account object." - ) - if not access_token and service_account_info is None: - raise MissingCredentialsError( - "Provide either X-GA-Access-Token or X-GA-Credentials." - ) +# --- Google Search Console ---------------------------------------------------- +def get_gsc_credentials( + x_gsc_access_token: str | None = Header( + default=None, + alias="X-GSC-Access-Token", + description="Ready OAuth2 access token used directly as a Bearer token. " + "Takes precedence over X-GSC-Credentials.", + ), + x_gsc_credentials: str | None = Header( + default=None, + alias="X-GSC-Credentials", + description="Base64-encoded Google service-account JSON key. The proxy " + "mints a short-lived access token from it (scope webmasters.readonly). " + "Used only if X-GSC-Access-Token is absent.", + ), + x_gsc_quota_project: str | None = Header( + default=None, + alias="X-GSC-Quota-Project", + description="Optional Google Cloud project id for quota/billing.", + ), +) -> GoogleCredentials: + return _build_google_credentials( + x_gsc_access_token, + x_gsc_credentials, + x_gsc_quota_project, + config.GSC_SCOPE, + token_header="X-GSC-Access-Token", + creds_header="X-GSC-Credentials", + ) - return GaCredentials( - access_token=access_token, - service_account_info=service_account_info, - quota_project=(x_ga_quota_project or "").strip() or None, + +# --- Google Ads --------------------------------------------------------------- +def get_google_ads_credentials( + x_gads_developer_token: str | None = Header( + default=None, + alias="X-GAds-Developer-Token", + description="Google Ads API developer token (from a Google Ads manager " + "account). Required for every Google Ads call.", + ), + x_gads_access_token: str | None = Header( + default=None, + alias="X-GAds-Access-Token", + description="Ready OAuth2 access token used directly as a Bearer token. " + "Takes precedence over X-GAds-Credentials.", + ), + x_gads_credentials: str | None = Header( + default=None, + alias="X-GAds-Credentials", + description="Base64-encoded Google service-account JSON key (scope " + "adwords). Requires domain-wide delegation; usually an access token is " + "easier. Used only if X-GAds-Access-Token is absent.", + ), + x_gads_login_customer_id: str | None = Header( + default=None, + alias="X-GAds-Login-Customer-Id", + description="Optional manager (MCC) customer id used as login-customer-id " + "header. Digits only, no dashes.", + ), + x_gads_quota_project: str | None = Header( + default=None, + alias="X-GAds-Quota-Project", + description="Optional Google Cloud project id for quota/billing.", + ), +) -> GoogleAdsCredentials: + developer_token = (x_gads_developer_token or "").strip() + if not developer_token: + raise MissingCredentialsError("X-GAds-Developer-Token header is required.") + + google = _build_google_credentials( + x_gads_access_token, + x_gads_credentials, + x_gads_quota_project, + config.GOOGLE_ADS_SCOPE, + token_header="X-GAds-Access-Token", + creds_header="X-GAds-Credentials", + ) + login_customer_id = (x_gads_login_customer_id or "").strip().replace("-", "") or None + return GoogleAdsCredentials( + google=google, + developer_token=developer_token, + login_customer_id=login_customer_id, ) diff --git a/app/main.py b/app/main.py index aec9a7e..05981c5 100644 --- a/app/main.py +++ b/app/main.py @@ -15,14 +15,15 @@ from fastapi import FastAPI from . import config from .errors import register_exception_handlers from .logging_config import get_logger -from .routers import ga_admin, ga_data, meta, sklik +from .routers import ga_admin, ga_data, googleads, gsc, meta, sklik logger = get_logger(__name__) ROOT_PATH = os.getenv("ROOT_PATH", "") DESCRIPTION = """ -Stateless proxy exposing **Google Analytics 4** and **Sklik** (Seznam) APIs. +Stateless proxy exposing **Google Analytics 4**, **Google Search Console**, +**Google Ads** and **Sklik** (Seznam) APIs. Veškeré přihlašovací údaje se posílají v každém requestu jako `X-` hlavičky – služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out**. @@ -31,65 +32,79 @@ služba si nic neukládá. Vyplníte je v Swaggeru po kliknutí na **Try it out* | --- | --- | --- | | Google Analytics | `X-GA-Access-Token` **nebo** `X-GA-Credentials` | jedna z nich | | Google Analytics | `X-GA-Quota-Project` | ne | +| Search Console | `X-GSC-Access-Token` **nebo** `X-GSC-Credentials` | jedna z nich | +| Search Console | `X-GSC-Quota-Project` | ne | +| Google Ads | `X-GAds-Developer-Token` | ano | +| Google Ads | `X-GAds-Access-Token` **nebo** `X-GAds-Credentials` | jedna z nich | +| Google Ads | `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project` | ne | | Sklik | `X-Sklik-Token` | ano | | Sklik | `X-Sklik-User-Id` | ne (jen pro agenturní/MCC přístup) | +Tři Google služby používají stejný princip přihlášení (Google OAuth) – liší se +jen prefixem hlavičky a oprávněním (scope). **Jeden service account lze použít +pro všechny tři** (stačí mu udělit přístup v dané službě a povolit příslušné +API). U Google Ads navíc vždy potřebujete *developer token*. + --- ## Kde vzít přihlašovací údaje -### 🔹 Google Analytics 4 - -Potřebujete dvě věci: **přístup k API** a **ID property** (číslo, na které se -ptáte). - -**ID property** (`property_id` v URL): v GA4 vpravo dole **Administrace → -Nastavení property** – nahoře je *ID property*, např. `123456789`. - -Pro přístup máte dvě možnosti (stačí jedna): - -**A) Service account – doporučeno pro automatizaci (`X-GA-Credentials`)** +### 🔹 Společné pro všechny Google služby – přístup přes service account 1. [Google Cloud Console](https://console.cloud.google.com/) → vytvořte nebo vyberte projekt. -2. **APIs & Services → Library** → povolte **Google Analytics Data API** - a **Google Analytics Admin API**. +2. **APIs & Services → Library** → povolte API podle toho, co budete volat: + *Google Analytics Data API* + *Google Analytics Admin API*, + *Google Search Console API*, *Google Ads API*. 3. **IAM & Admin → Service Accounts → Create service account**. -4. U vytvořeného účtu **Keys → Add key → Create new key → JSON** – stáhne se - soubor s klíčem. -5. Z JSON souboru zkopírujte `client_email` a v GA4 ho přidejte k property: - **Administrace → Správa přístupu k property → +** , role **Viewer** - (Čtenář). -6. Celý JSON soubor zakódujte do **base64** a vložte do hlavičky - `X-GA-Credentials`: +4. U účtu **Keys → Add key → Create new key → JSON** – stáhne se klíč. +5. Klíč zakódujte do **base64** a vložte do příslušné `*-Credentials` hlavičky: - Windows PowerShell: `[Convert]::ToBase64String([IO.File]::ReadAllBytes("klic.json"))` - Linux/macOS: `base64 -w0 klic.json` -**B) Hotový OAuth2 token – pro rychlý test (`X-GA-Access-Token`)** +Service account (jeho `client_email` z JSON) pak musíte **přidat jako uživatele +v dané službě** – viz níže. Místo service accountu lze vždy poslat i hotový +OAuth2 *access token* v `*-Access-Token` (např. z +[OAuth Playground](https://developers.google.com/oauthplayground/) se správným +scope); platí ~1 hodinu. -1. [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/). -2. Vlevo zadejte scope `https://www.googleapis.com/auth/analytics.readonly` - a klikněte **Authorize APIs** (přihlaste se Google účtem, který má přístup - k property). -3. **Exchange authorization code for tokens** → zkopírujte *Access token* do - hlavičky `X-GA-Access-Token`. Pozor: platí jen ~1 hodinu. +### 🔹 Google Analytics 4 (`X-GA-*`) -### 🔹 Sklik +- **ID property** (`property_id` v URL): GA4 → **Administrace → Nastavení + property** → *ID property*, např. `123456789`. +- Přístup: service account `client_email` přidejte v **Administrace → Správa + přístupu k property** jako **Viewer**. Scope: `analytics.readonly`. -Potřebujete **API token (klíč)** z účtu, jehož data chcete číst. +### 🔹 Google Search Console (`X-GSC-*`) + +- **siteUrl**: adresa property, buď URL-prefix (`https://example.com/`) nebo + doménová property (`sc-domain:example.com`). Posílá se jako parametr `siteUrl`. +- Přístup: v [Search Console](https://search.google.com/search-console) → + **Nastavení → Uživatelé a oprávnění** přidejte `client_email` service accountu + (role *Full* nebo *Restricted*). Scope: `webmasters.readonly`. + +### 🔹 Google Ads (`X-GAds-*`) + +- **Developer token** (`X-GAds-Developer-Token`, povinný): v **Google Ads + manager (MCC) účtu → Tools → API Center**. Token musí mít schválený přístup. +- **customer_id** (v URL): 10místné číslo účtu (bez pomlček). +- **login-customer-id** (`X-GAds-Login-Customer-Id`, volitelné): ID manager + (MCC) účtu, přes který přistupujete k podřízenému účtu. +- Přístup: nejjednodušší je poslat hotový OAuth2 *access token* se scope + `https://www.googleapis.com/auth/adwords` v `X-GAds-Access-Token`. + Service account funguje jen s *domain-wide delegation*. + +### 🔹 Sklik (`X-Sklik-Token`) 1. Přihlaste se na [sklik.cz](https://www.sklik.cz/). -2. Vpravo nahoře klikněte na **své uživatelské jméno → Nastavení**. -3. V nastavení účtu otevřete sekci **Přístup k API Drak**. -4. Klikněte na **Zobrazit token** a token zkopírujte do hlavičky - `X-Sklik-Token`. +2. Vpravo nahoře **své uživatelské jméno → Nastavení**. +3. Sekce **Přístup k API Drak** → **Zobrazit token**. +4. Token zkopírujte do hlavičky `X-Sklik-Token`. > ⚠️ Každé vygenerování nového tokenu **zneplatní ten předchozí**. Token je -> vázaný na účet, pod kterým jste přihlášeni. -> -> Spravujete-li cizí účty (agentura/MCC), vložte cílové `userId` do hlavičky -> `X-Sklik-User-Id`. +> vázaný na účet, pod kterým jste přihlášeni. Pro správu cizích účtů +> (agentura/MCC) vložte cílové `userId` do hlavičky `X-Sklik-User-Id`. --- @@ -108,6 +123,8 @@ register_exception_handlers(app) app.include_router(meta.router) app.include_router(ga_data.router) app.include_router(ga_admin.router) +app.include_router(gsc.router) +app.include_router(googleads.router) app.include_router(sklik.router) logger.info( diff --git a/app/routers/ga_admin.py b/app/routers/ga_admin.py index cc889f7..9020bc0 100644 --- a/app/routers/ga_admin.py +++ b/app/routers/ga_admin.py @@ -10,8 +10,9 @@ from typing import Any from fastapi import APIRouter, Depends, Path, Query -from ..clients.ga_client import GoogleAnalyticsClient -from ..credentials import GaCredentials, get_ga_credentials +from .. import config +from ..clients.google import GoogleApiClient +from ..credentials import GoogleCredentials, get_ga_credentials router = APIRouter(prefix="/ga/admin", tags=["google-analytics: admin"]) @@ -21,15 +22,18 @@ def _property(property_id: str) -> str: return pid if pid.startswith("properties/") else f"properties/{pid}" +def _client(creds: GoogleCredentials) -> GoogleApiClient: + return GoogleApiClient(creds, service_name="Google Analytics") + + @router.get("/accounts", summary="List accessible GA4 accounts") async def list_accounts( page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"), page_token: str | None = Query(None, alias="pageToken"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: params = _paging(page_size, page_token) - client = GoogleAnalyticsClient(creds) - return await client.admin_get("/accounts", params=params) + return await _client(creds).get(f"{config.GA_ADMIN_BASE_URL}/accounts", params) @router.get( @@ -39,11 +43,12 @@ async def list_accounts( async def list_account_summaries( page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"), page_token: str | None = Query(None, alias="pageToken"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: params = _paging(page_size, page_token) - client = GoogleAnalyticsClient(creds) - return await client.admin_get("/accountSummaries", params=params) + return await _client(creds).get( + f"{config.GA_ADMIN_BASE_URL}/accountSummaries", params + ) @router.get("/properties", summary="List properties under an account") @@ -56,14 +61,13 @@ async def list_properties( page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"), page_token: str | None = Query(None, alias="pageToken"), show_deleted: bool | None = Query(None, alias="showDeleted"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: params: dict[str, Any] = {"filter": f"parent:accounts/{account_id.strip()}"} params.update(_paging(page_size, page_token)) if show_deleted is not None: params["showDeleted"] = show_deleted - client = GoogleAnalyticsClient(creds) - return await client.admin_get("/properties", params=params) + return await _client(creds).get(f"{config.GA_ADMIN_BASE_URL}/properties", params) @router.get( @@ -72,10 +76,11 @@ async def list_properties( ) async def get_property( property_id: str = Path(..., description="GA4 property id"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.admin_get(f"/{_property(property_id)}") + return await _client(creds).get( + f"{config.GA_ADMIN_BASE_URL}/{_property(property_id)}" + ) @router.get( @@ -86,12 +91,11 @@ async def list_data_streams( property_id: str = Path(..., description="GA4 property id"), page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"), page_token: str | None = Query(None, alias="pageToken"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: params = _paging(page_size, page_token) - client = GoogleAnalyticsClient(creds) - return await client.admin_get( - f"/{_property(property_id)}/dataStreams", params=params + return await _client(creds).get( + f"{config.GA_ADMIN_BASE_URL}/{_property(property_id)}/dataStreams", params ) diff --git a/app/routers/ga_data.py b/app/routers/ga_data.py index 49938aa..e0f9e83 100644 --- a/app/routers/ga_data.py +++ b/app/routers/ga_data.py @@ -13,8 +13,9 @@ from typing import Any from fastapi import APIRouter, Body, Depends, Path -from ..clients.ga_client import GoogleAnalyticsClient -from ..credentials import GaCredentials, get_ga_credentials +from .. import config +from ..clients.google import GoogleApiClient +from ..credentials import GoogleCredentials, get_ga_credentials router = APIRouter(prefix="/ga/data", tags=["google-analytics: data"]) @@ -32,6 +33,14 @@ def _property(property_id: str) -> str: return pid if pid.startswith("properties/") else f"properties/{pid}" +def _client(creds: GoogleCredentials) -> GoogleApiClient: + return GoogleApiClient(creds, service_name="Google Analytics") + + +def _url(property_id: str, suffix: str) -> str: + return f"{config.GA_DATA_BASE_URL}/{_property(property_id)}{suffix}" + + @router.post( "/properties/{property_id}/runReport", summary="Run a GA4 report", @@ -39,10 +48,9 @@ def _property(property_id: str) -> str: async def run_report( property_id: str = Path(..., description="GA4 property id, e.g. 123456789"), body: dict[str, Any] = Body(..., examples=[_RUN_REPORT_EXAMPLE]), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post(f"/{_property(property_id)}:runReport", body) + return await _client(creds).post(_url(property_id, ":runReport"), body) @router.post( @@ -52,12 +60,9 @@ async def run_report( async def run_pivot_report( property_id: str = Path(..., description="GA4 property id"), body: dict[str, Any] = Body(...), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post( - f"/{_property(property_id)}:runPivotReport", body - ) + return await _client(creds).post(_url(property_id, ":runPivotReport"), body) @router.post( @@ -67,12 +72,9 @@ async def run_pivot_report( async def batch_run_reports( property_id: str = Path(..., description="GA4 property id"), body: dict[str, Any] = Body(...), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post( - f"/{_property(property_id)}:batchRunReports", body - ) + return await _client(creds).post(_url(property_id, ":batchRunReports"), body) @router.post( @@ -82,11 +84,10 @@ async def batch_run_reports( async def batch_run_pivot_reports( property_id: str = Path(..., description="GA4 property id"), body: dict[str, Any] = Body(...), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post( - f"/{_property(property_id)}:batchRunPivotReports", body + return await _client(creds).post( + _url(property_id, ":batchRunPivotReports"), body ) @@ -97,12 +98,9 @@ async def batch_run_pivot_reports( async def run_realtime_report( property_id: str = Path(..., description="GA4 property id"), body: dict[str, Any] = Body(...), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post( - f"/{_property(property_id)}:runRealtimeReport", body - ) + return await _client(creds).post(_url(property_id, ":runRealtimeReport"), body) @router.post( @@ -112,12 +110,9 @@ async def run_realtime_report( async def check_compatibility( property_id: str = Path(..., description="GA4 property id"), body: dict[str, Any] = Body(...), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_post( - f"/{_property(property_id)}:checkCompatibility", body - ) + return await _client(creds).post(_url(property_id, ":checkCompatibility"), body) @router.get( @@ -126,7 +121,6 @@ async def check_compatibility( ) async def get_metadata( property_id: str = Path(..., description="GA4 property id"), - creds: GaCredentials = Depends(get_ga_credentials), + creds: GoogleCredentials = Depends(get_ga_credentials), ) -> Any: - client = GoogleAnalyticsClient(creds) - return await client.data_get(f"/{_property(property_id)}/metadata") + return await _client(creds).get(_url(property_id, "/metadata")) diff --git a/app/routers/googleads.py b/app/routers/googleads.py new file mode 100644 index 0000000..f591f4d --- /dev/null +++ b/app/routers/googleads.py @@ -0,0 +1,91 @@ +"""Google Ads - reporting via GAQL. + +GoogleAdsService search / searchStream accept a GAQL query and stream rows back; +this covers virtually all Google Ads reporting. Request/response bodies are +forwarded as-is. + +Auth differs from the other Google services: besides the OAuth Bearer token it +needs a **developer token** (``X-GAds-Developer-Token`` -> ``developer-token``) +and, for manager (MCC) access, an optional ``X-GAds-Login-Customer-Id`` +(-> ``login-customer-id``). The API version is configurable via +``GOOGLE_ADS_API_VERSION`` because Google deprecates versions yearly. + +Credentials: see ``app.credentials.get_google_ads_credentials``. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Body, Depends, Path + +from .. import config +from ..clients.google import GoogleApiClient +from ..credentials import GoogleAdsCredentials, get_google_ads_credentials + +router = APIRouter(prefix="/googleads", tags=["google-ads"]) + +_SEARCH_EXAMPLE = { + "query": ( + "SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, " + "metrics.cost_micros FROM campaign WHERE segments.date DURING LAST_7_DAYS" + ) +} + + +def _client(creds: GoogleAdsCredentials) -> GoogleApiClient: + headers = {"developer-token": creds.developer_token} + if creds.login_customer_id: + headers["login-customer-id"] = creds.login_customer_id + return GoogleApiClient( + creds.google, extra_headers=headers, service_name="Google Ads" + ) + + +def _customer(customer_id: str) -> str: + # Customer ids are digits only (callers may include dashes for readability). + return customer_id.strip().replace("-", "") + + +def _base(customer_id: str) -> str: + return ( + f"{config.GOOGLE_ADS_BASE_URL}/{config.GOOGLE_ADS_API_VERSION}" + f"/customers/{_customer(customer_id)}/googleAds" + ) + + +@router.post( + "/customers/{customer_id}/search", + summary="Run a GAQL query (paginated)", +) +async def search( + customer_id: str = Path(..., description="Google Ads customer id (digits)"), + body: dict[str, Any] = Body(..., examples=[_SEARCH_EXAMPLE]), + creds: GoogleAdsCredentials = Depends(get_google_ads_credentials), +) -> Any: + return await _client(creds).post(f"{_base(customer_id)}:search", body) + + +@router.post( + "/customers/{customer_id}/searchStream", + summary="Run a GAQL query (streamed, whole result set in one response)", +) +async def search_stream( + customer_id: str = Path(..., description="Google Ads customer id (digits)"), + body: dict[str, Any] = Body(..., examples=[_SEARCH_EXAMPLE]), + creds: GoogleAdsCredentials = Depends(get_google_ads_credentials), +) -> Any: + return await _client(creds).post(f"{_base(customer_id)}:searchStream", body) + + +@router.get( + "/customers:listAccessibleCustomers", + summary="List customer ids the credentials can access", +) +async def list_accessible_customers( + creds: GoogleAdsCredentials = Depends(get_google_ads_credentials), +) -> Any: + url = ( + f"{config.GOOGLE_ADS_BASE_URL}/{config.GOOGLE_ADS_API_VERSION}" + "/customers:listAccessibleCustomers" + ) + return await _client(creds).get(url) diff --git a/app/routers/gsc.py b/app/routers/gsc.py new file mode 100644 index 0000000..ca5b8e9 --- /dev/null +++ b/app/routers/gsc.py @@ -0,0 +1,122 @@ +"""Google Search Console - read API. + +Search Analytics, Sites and Sitemaps live under the Webmasters v3 API; URL +Inspection lives under searchconsole.googleapis.com/v1. Request/response bodies +are forwarded as-is. + +The site URL (e.g. ``https://example.com/`` or ``sc-domain:example.com``) is +passed as a query parameter and URL-encoded into the upstream path - this keeps +our routes clean and avoids ambiguity with the slashes/colons it contains. + +Credentials: X-GSC-Access-Token (preferred) or X-GSC-Credentials. +""" +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from fastapi import APIRouter, Body, Depends, Query + +from .. import config +from ..clients.google import GoogleApiClient +from ..credentials import GoogleCredentials, get_gsc_credentials + +router = APIRouter(prefix="/gsc", tags=["google-search-console"]) + +_QUERY_EXAMPLE = { + "startDate": "2026-05-01", + "endDate": "2026-05-31", + "dimensions": ["query", "page"], + "rowLimit": 100, +} + +_SITE_URL_DESC = ( + "Property in Search Console: a URL-prefix property (e.g. " + "https://example.com/) or a domain property (e.g. sc-domain:example.com)." +) + + +def _client(creds: GoogleCredentials) -> GoogleApiClient: + return GoogleApiClient(creds, service_name="Search Console") + + +def _site(site_url: str) -> str: + # The siteUrl is a single path segment and must be fully URL-encoded. + return quote(site_url.strip(), safe="") + + +@router.post( + "/searchAnalytics/query", + summary="Query Search Console search traffic (clicks, impressions, CTR, position)", +) +async def search_analytics_query( + site_url: str = Query(..., alias="siteUrl", description=_SITE_URL_DESC), + body: dict[str, Any] = Body(..., examples=[_QUERY_EXAMPLE]), + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + url = f"{config.GSC_DATA_BASE_URL}/sites/{_site(site_url)}/searchAnalytics/query" + return await _client(creds).post(url, body) + + +@router.get("/sites", summary="List sites in the account") +async def list_sites( + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + return await _client(creds).get(f"{config.GSC_DATA_BASE_URL}/sites") + + +@router.get("/site", summary="Get a single site's info and permission level") +async def get_site( + site_url: str = Query(..., alias="siteUrl", description=_SITE_URL_DESC), + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + return await _client(creds).get( + f"{config.GSC_DATA_BASE_URL}/sites/{_site(site_url)}" + ) + + +@router.get("/sitemaps", summary="List sitemaps submitted for a site") +async def list_sitemaps( + site_url: str = Query(..., alias="siteUrl", description=_SITE_URL_DESC), + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + return await _client(creds).get( + f"{config.GSC_DATA_BASE_URL}/sites/{_site(site_url)}/sitemaps" + ) + + +@router.get("/sitemap", summary="Get information about a specific sitemap") +async def get_sitemap( + site_url: str = Query(..., alias="siteUrl", description=_SITE_URL_DESC), + feedpath: str = Query( + ..., description="Full URL of the sitemap, e.g. https://example.com/sitemap.xml" + ), + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + url = ( + f"{config.GSC_DATA_BASE_URL}/sites/{_site(site_url)}" + f"/sitemaps/{quote(feedpath.strip(), safe='')}" + ) + return await _client(creds).get(url) + + +@router.post( + "/urlInspection", + summary="Inspect the Google index status of a URL", +) +async def inspect_url( + body: dict[str, Any] = Body( + ..., + examples=[ + { + "inspectionUrl": "https://example.com/some-page", + "siteUrl": "https://example.com/", + "languageCode": "cs", + } + ], + description="Requires inspectionUrl and siteUrl; languageCode is optional.", + ), + creds: GoogleCredentials = Depends(get_gsc_credentials), +) -> Any: + url = f"{config.GSC_INSPECT_BASE_URL}/urlInspection/index:inspect" + return await _client(creds).post(url, body) diff --git a/documentation/google-ads.md b/documentation/google-ads.md new file mode 100644 index 0000000..b67a2e5 --- /dev/null +++ b/documentation/google-ads.md @@ -0,0 +1,73 @@ +# Google Ads + +Proxy over the Google Ads API `GoogleAdsService` (GAQL search / searchStream), +which covers virtually all Google Ads reporting. Bodies are forwarded as-is. + +The API version is in an env var (`GOOGLE_ADS_API_VERSION`, default `v19`) +because Google deprecates versions roughly yearly — bump it without a code +change. Base URL: `https://googleads.googleapis.com/{version}`. + +## Credentials + +Google Ads needs more than a Bearer token: + +| Header | Required | Meaning | +| --- | --- | --- | +| `X-GAds-Developer-Token` | **yes** | Developer token from a Google Ads manager account → `developer-token`. | +| `X-GAds-Access-Token` | one of these | Ready OAuth2 access token (Bearer). | +| `X-GAds-Credentials` | one of these | Base64 service-account JSON (scope `adwords`); needs domain-wide delegation. | +| `X-GAds-Login-Customer-Id` | no | Manager (MCC) id → `login-customer-id`. Digits only. | +| `X-GAds-Quota-Project` | no | GCP project id → `x-goog-user-project`. | + +> For Google Ads a service account works only with **domain-wide delegation**; +> in practice the simplest path is a ready OAuth2 access token (obtained from a +> refresh token with scope `https://www.googleapis.com/auth/adwords`) in +> `X-GAds-Access-Token`. + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/googleads/customers/{customer_id}/search` | GAQL query, paginated. | +| POST | `/googleads/customers/{customer_id}/searchStream` | GAQL query, whole result set in one streamed response. | +| GET | `/googleads/customers:listAccessibleCustomers` | Customer ids the credentials can access. | + +`customer_id` is the 10-digit account id (dashes are stripped for you). + +### Query body (GAQL) + +```json +{ + "query": "SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros FROM campaign WHERE segments.date DURING LAST_7_DAYS" +} +``` + +GAQL reference: +. + +## Kde získat údaje (návod pro klienta) + +- **Developer token**: v Google Ads **manager (MCC) účtu → Tools → API Center**. + Token musí mít schválený (approved) přístup, jinak vrací jen test účty. +- **customer_id**: 10místné číslo účtu (vpravo nahoře v Google Ads, bez pomlček). +- **login-customer-id**: ID manager účtu, přes který přistupujete (volitelné). +- **Access token**: vygenerujte z refresh tokenu se scope + `https://www.googleapis.com/auth/adwords` (např. OAuth Playground) → hlavička + `X-GAds-Access-Token`. + +## Errors + +Upstream errors keep the Google Ads status and body (often a detailed +`GoogleAdsFailure`) in `upstream_body`. A common one: developer token not +approved, or `login-customer-id` required for manager access. + +## curl example + +```bash +curl -X POST "https://services.csbot.cz/apps/analytics/googleads/customers/1234567890/searchStream" \ + -H "X-GAds-Developer-Token: " \ + -H "X-GAds-Access-Token: ya29...." \ + -H "X-GAds-Login-Customer-Id: 9876543210" \ + -H "Content-Type: application/json" \ + -d '{"query":"SELECT campaign.name, metrics.clicks FROM campaign WHERE segments.date DURING LAST_7_DAYS"}' +``` diff --git a/documentation/google-search-console.md b/documentation/google-search-console.md new file mode 100644 index 0000000..fa62536 --- /dev/null +++ b/documentation/google-search-console.md @@ -0,0 +1,71 @@ +# Google Search Console + +Proxy over the Search Console API. Search Analytics, Sites and Sitemaps use the +Webmasters v3 API (`www.googleapis.com/webmasters/v3`); URL Inspection uses +`searchconsole.googleapis.com/v1`. Read-only. + +## Credentials + +Same Google OAuth model as Analytics, token wins over service account: + +| Header | Meaning | +| --- | --- | +| `X-GSC-Access-Token` | Ready OAuth2 access token (Bearer). | +| `X-GSC-Credentials` | Base64 service-account JSON; token minted with scope `https://www.googleapis.com/auth/webmasters.readonly`. | +| `X-GSC-Quota-Project` | Optional GCP project id → `x-goog-user-project`. | + +The service account (or token's user) must be added to the property in Search +Console → **Settings → Users and permissions**. + +## Site URL + +Every endpoint takes the property as the `siteUrl` query parameter (the proxy +URL-encodes it into the upstream path): + +- URL-prefix property: `https://example.com/` +- Domain property: `sc-domain:example.com` + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/gsc/searchAnalytics/query?siteUrl=` | Search traffic (clicks, impressions, CTR, position). | +| GET | `/gsc/sites` | List sites in the account. | +| GET | `/gsc/site?siteUrl=` | Single site info + permission level. | +| GET | `/gsc/sitemaps?siteUrl=` | List submitted sitemaps. | +| GET | `/gsc/sitemap?siteUrl=&feedpath=` | One sitemap's details. | +| POST | `/gsc/urlInspection` | Index status of a URL (body has `inspectionUrl`, `siteUrl`, `languageCode`). | + +### Search Analytics query body + +```json +{ + "startDate": "2026-05-01", + "endDate": "2026-05-31", + "dimensions": ["query", "page"], + "rowLimit": 100 +} +``` + +Forwarded unchanged; see +. + +## Kde získat údaje (návod pro klienta) + +1. Service account a base64 JSON klíč – viz hlavní popis ve Swaggeru / [google-analytics.md](google-analytics.md). +2. V [Search Console](https://search.google.com/search-console) → **Nastavení → + Uživatelé a oprávnění** přidejte `client_email` service accountu. +3. `siteUrl` = adresa property tak, jak je uvedená v Search Console. + +## Not wired (deliberately) + +Writes — submitting/deleting sitemaps, adding/removing sites. They need the +`webmasters` (read-write) scope; add them if management is required. + +## curl example + +```bash +curl -X POST "https://services.csbot.cz/apps/analytics/gsc/searchAnalytics/query?siteUrl=https%3A%2F%2Fexample.com%2F" \ + -H "X-GSC-Access-Token: ya29...." -H "Content-Type: application/json" \ + -d '{"startDate":"2026-05-01","endDate":"2026-05-31","dimensions":["query"]}' +``` diff --git a/documentation/overview.md b/documentation/overview.md index 56c61f2..b8bdb9d 100644 --- a/documentation/overview.md +++ b/documentation/overview.md @@ -1,10 +1,17 @@ # analytics — overview -A stateless multi-tenant API proxy exposing two upstream services under one +A stateless multi-tenant API proxy exposing four upstream services under one FastAPI app: 1. **Google Analytics 4** — Data API (reporting) + Admin API (read). -2. **Sklik** (Seznam) — Drak JSON-RPC API. +2. **Google Search Console** — Search Analytics, Sites, Sitemaps, URL Inspection (read). +3. **Google Ads** — GAQL reporting (search / searchStream). +4. **Sklik** (Seznam) — Drak JSON-RPC API. + +The three Google services share one OAuth mechanism (token wins over service +account) — they differ only in the OAuth *scope* and the header prefix +(`X-GA-*`, `X-GSC-*`, `X-GAds-*`). Google Ads additionally needs a developer +token. The structure mirrors the sibling `idoklad` / `csob` services (config→env, credentials→headers, client per upstream, routers, central exception handling, @@ -25,17 +32,19 @@ Swagger at `/docs`), adapted to Python/FastAPI. ``` app/ - config.py env-driven config (base URLs, scope, timeout) — no secrets + config.py env-driven config (base URLs, scopes, timeout) — no secrets logging_config.py get_logger(); secrets are never logged errors.py MissingCredentialsError, UpstreamError + handlers - credentials.py X- header dependencies (GA + Sklik) + credentials.py X- header dependencies (GA / GSC / Ads / Sklik) clients/ - ga_client.py GA Data/Admin HTTP client + service-account token minting + google.py shared Google client: Bearer/SA token minting + requests sklik_client.py Sklik JSON-RPC client (login + session + report paging) routers/ meta.py /health, /version - ga_data.py /ga/data/... - ga_admin.py /ga/admin/... + ga_data.py /ga/data/... (Google Analytics Data) + ga_admin.py /ga/admin/... (Google Analytics Admin) + gsc.py /gsc/... (Search Console) + googleads.py /googleads/... (Google Ads) sklik.py /sklik/... main.py app factory, root_path, router + handler registration ``` @@ -50,14 +59,18 @@ routes are unprefixed (Caddy `handle_path` strips the prefix). | Upstream | Header(s) | Behaviour | | --- | --- | --- | -| Google Analytics | `X-GA-Access-Token` **or** `X-GA-Credentials` (+ `X-GA-Quota-Project`) | Token used directly; else a token is minted from the base64 service-account JSON (scope `analytics.readonly`) and cached in memory until ~60 s before expiry. | +| Google Analytics | `X-GA-Access-Token` **or** `X-GA-Credentials` (+ `X-GA-Quota-Project`) | Token used directly; else minted from base64 service-account JSON (scope `analytics.readonly`) and cached in memory until ~60 s before expiry. | +| Search Console | `X-GSC-Access-Token` **or** `X-GSC-Credentials` (+ `X-GSC-Quota-Project`) | Same as GA, scope `webmasters.readonly`. | +| Google Ads | `X-GAds-Developer-Token` (req) + `X-GAds-Access-Token` **or** `X-GAds-Credentials` (+ `X-GAds-Login-Customer-Id`, `X-GAds-Quota-Project`) | Same OAuth (scope `adwords`) plus `developer-token` / `login-customer-id` headers forwarded upstream. | | Sklik | `X-Sklik-Token` (+ `X-Sklik-User-Id`) | `client.loginByToken` per request → session injected into the call. | ## Deliberately not wired -- **GA Admin write operations** (create/update/delete properties, streams). The - requested scope is read-only (`analytics.readonly`); add `analytics.edit` and - endpoints if management is needed later. +- **Write operations** across the Google services: GA Admin (create/update + properties, streams), Search Console (submit/delete sitemaps, add/remove + sites), Google Ads mutates (create/update campaigns etc.). All requested + scopes are read-only; add the read-write scope + endpoints if management is + needed later. Google Ads exposes reporting (GAQL) only for now. - **Sklik header-credential encryption.** Same deferral as `idoklad`/`csob`: header values are plaintext over TLS for now. - **Sklik session reuse across requests** — the chosen model logs in per