search a ads

This commit is contained in:
JiriUhlir
2026-06-18 12:43:42 +02:00
parent 9cd5c8011b
commit 125b112967
12 changed files with 748 additions and 191 deletions
+26 -3
View File
@@ -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.
@@ -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,
+24
View File
@@ -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.
+157 -42
View File
@@ -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,
)
+56 -39
View File
@@ -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(
+22 -18
View File
@@ -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
)
+26 -32
View File
@@ -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"))
+91
View File
@@ -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)
+122
View File
@@ -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)
+73
View File
@@ -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:
<https://developers.google.com/google-ads/api/docs/query/overview>.
## 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: <DEV_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"}'
```
+71
View File
@@ -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
<https://developers.google.com/webmaster-tools/v1/searchanalytics/query>.
## 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"]}'
```
+24 -11
View File
@@ -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