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
+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)