search a ads
This commit is contained in:
@@ -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,
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}'
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user