From 8056288000a1f90bf1d0b0ca9ad1009c65f81ede Mon Sep 17 00:00:00 2001 From: Zach Leventer Date: Fri, 24 Apr 2026 17:15:34 -0400 Subject: [PATCH] feat: add Google Search Console integration (closes #18) --- config.yaml.example | 8 +++ pyproject.toml | 1 + src/adloop/auth.py | 2 + src/adloop/config.py | 10 +++ src/adloop/gsc/__init__.py | 0 src/adloop/gsc/client.py | 20 ++++++ src/adloop/gsc/reports.py | 139 +++++++++++++++++++++++++++++++++++++ src/adloop/server.py | 64 +++++++++++++++++ 8 files changed, 244 insertions(+) create mode 100644 src/adloop/gsc/__init__.py create mode 100644 src/adloop/gsc/client.py create mode 100644 src/adloop/gsc/reports.py diff --git a/config.yaml.example b/config.yaml.example index 054ddae..38e821b 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -37,6 +37,14 @@ ads: # Required for API access — even if you only manage one account. login_customer_id: "" +gsc: + # Your Google Search Console site URL. + # Use the exact URL shown in the GSC property selector, e.g.: + # "https://example.com/" (URL-prefix property) + # "sc-domain:example.com" (Domain property) + # Leave empty to pass site_url as a parameter in each tool call instead. + site_url: "" + safety: # Maximum daily budget allowed per campaign (in your account's currency). # AdLoop will reject any campaign creation or budget change above this. diff --git a/pyproject.toml b/pyproject.toml index b5c02b5..db94e27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "google-analytics-data>=0.20.0", "google-analytics-admin>=0.27.0", "google-auth-oauthlib>=1.0.0", + "google-api-python-client>=2.0.0", "pyyaml>=6.0", ] diff --git a/src/adloop/auth.py b/src/adloop/auth.py index 3eb8f9d..6c6e4bb 100644 --- a/src/adloop/auth.py +++ b/src/adloop/auth.py @@ -18,11 +18,13 @@ "https://www.googleapis.com/auth/analytics.readonly", "https://www.googleapis.com/auth/analytics.edit", "https://www.googleapis.com/auth/adwords", + "https://www.googleapis.com/auth/webmasters.readonly", ] _GA4_SCOPES = [ "https://www.googleapis.com/auth/analytics.readonly", "https://www.googleapis.com/auth/analytics.edit", + "https://www.googleapis.com/auth/webmasters.readonly", ] _ADS_SCOPES = [ diff --git a/src/adloop/config.py b/src/adloop/config.py index 220c500..a17426e 100644 --- a/src/adloop/config.py +++ b/src/adloop/config.py @@ -32,6 +32,11 @@ class AdsConfig: login_customer_id: str = "" +@dataclass +class GscConfig: + site_url: str = "" # e.g. "https://example.com/" or "sc-domain:example.com" + + @dataclass class SafetyConfig: max_daily_budget: float = 50.0 @@ -46,6 +51,7 @@ class AdLoopConfig: google: GoogleConfig = field(default_factory=GoogleConfig) ga4: GA4Config = field(default_factory=GA4Config) ads: AdsConfig = field(default_factory=AdsConfig) + gsc: GscConfig = field(default_factory=GscConfig) safety: SafetyConfig = field(default_factory=SafetyConfig) # Absolute path the config was resolved from (even if it did not exist # on disk when loaded). Used by the runtime to tell callers exactly @@ -81,6 +87,7 @@ def load_config(config_path: str | None = None) -> AdLoopConfig: google_raw = raw.get("google", {}) ga4_raw = raw.get("ga4", {}) ads_raw = raw.get("ads", {}) + gsc_raw = raw.get("gsc", {}) safety_raw = raw.get("safety", {}) return AdLoopConfig( @@ -97,6 +104,9 @@ def load_config(config_path: str | None = None) -> AdLoopConfig: customer_id=ads_raw.get("customer_id", ""), login_customer_id=ads_raw.get("login_customer_id", ""), ), + gsc=GscConfig( + site_url=gsc_raw.get("site_url", ""), + ), safety=SafetyConfig( max_daily_budget=safety_raw.get("max_daily_budget", 50.0), max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100), diff --git a/src/adloop/gsc/__init__.py b/src/adloop/gsc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adloop/gsc/client.py b/src/adloop/gsc/client.py new file mode 100644 index 0000000..77b4b49 --- /dev/null +++ b/src/adloop/gsc/client.py @@ -0,0 +1,20 @@ +"""Google Search Console API client wrapper.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from googleapiclient.discovery import Resource + + from adloop.config import AdLoopConfig + + +def get_gsc_client(config: AdLoopConfig) -> Resource: + """Return an authenticated Google Search Console API client.""" + from googleapiclient.discovery import build + + from adloop.auth import get_ga4_credentials + + credentials = get_ga4_credentials(config) + return build("searchconsole", "v1", credentials=credentials) diff --git a/src/adloop/gsc/reports.py b/src/adloop/gsc/reports.py new file mode 100644 index 0000000..f3fa6d6 --- /dev/null +++ b/src/adloop/gsc/reports.py @@ -0,0 +1,139 @@ +"""Google Search Console report tools.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +def list_gsc_sites(config: AdLoopConfig) -> dict: + """List all Google Search Console properties the authenticated user can access.""" + from adloop.gsc.client import get_gsc_client + + client = get_gsc_client(config) + result = client.sites().list().execute() + + sites = result.get("siteEntry", []) + return { + "sites": [ + { + "site_url": s["siteUrl"], + "permission_level": s.get("permissionLevel", "unknown"), + } + for s in sites + ], + "total": len(sites), + } + + +def run_gsc_report( + config: AdLoopConfig, + *, + site_url: str = "", + dimensions: list[str] | None = None, + date_range_start: str = "7daysAgo", + date_range_end: str = "today", + limit: int = 100, + search_type: Literal["web", "image", "video", "news", "discover", "googleNews"] = "web", + dimension_filter_groups: list[dict] | None = None, +) -> dict: + """Run a Google Search Console search analytics report. + + Returns clicks, impressions, CTR, and average position for the + requested dimensions and date range. + + dimensions: one or more of ["query", "page", "country", "device", "date"] + date_range_start / date_range_end: ISO dates (YYYY-MM-DD) or relative + values like "7daysAgo", "30daysAgo", "today" + search_type: "web" (default), "image", "video", "news", "discover", + "googleNews" + dimension_filter_groups: optional list of GSC DimensionFilterGroup dicts + to filter by query, page, country, or device. Example: + [{"filters": [{"dimension": "query", "operator": "contains", + "expression": "analytics"}]}] + limit: maximum number of rows to return (default 100, max 25000) + """ + from adloop.gsc.client import get_gsc_client + + if not site_url: + site_url = config.gsc.site_url + + if not site_url: + return { + "error": "site_url is required. Pass it as an argument or set " + "gsc.site_url in ~/.adloop/config.yaml." + } + + if not dimensions: + dimensions = ["query"] + + # Resolve relative date shorthands to ISO dates + start_date = _resolve_date(date_range_start) + end_date = _resolve_date(date_range_end) + + body: dict = { + "startDate": start_date, + "endDate": end_date, + "dimensions": dimensions, + "type": search_type, + "rowLimit": min(limit, 25_000), + "startRow": 0, + } + + if dimension_filter_groups: + body["dimensionFilterGroups"] = dimension_filter_groups + + from adloop.gsc.client import get_gsc_client + + client = get_gsc_client(config) + result = ( + client.searchanalytics() + .query(siteUrl=site_url, body=body) + .execute() + ) + + rows = result.get("rows", []) + formatted = [] + for row in rows: + entry: dict = {} + for i, dim in enumerate(dimensions): + entry[dim] = row["keys"][i] + entry["clicks"] = row.get("clicks", 0) + entry["impressions"] = row.get("impressions", 0) + entry["ctr"] = round(row.get("ctr", 0.0) * 100, 2) # as % + entry["position"] = round(row.get("position", 0.0), 1) + formatted.append(entry) + + return { + "site_url": site_url, + "date_range": {"start": start_date, "end": end_date}, + "search_type": search_type, + "dimensions": dimensions, + "rows": formatted, + "total_rows": len(formatted), + } + + +def _resolve_date(value: str) -> str: + """Resolve a relative date string to ISO format (YYYY-MM-DD).""" + import re + from datetime import date, timedelta + + value = value.strip() + if re.match(r"^\d{4}-\d{2}-\d{2}$", value): + return value + + today = date.today() + if value == "today": + return today.isoformat() + if value == "yesterday": + return (today - timedelta(days=1)).isoformat() + + m = re.match(r"^(\d+)daysAgo$", value) + if m: + return (today - timedelta(days=int(m.group(1)))).isoformat() + + # Unknown format — pass through and let the API surface the error + return value diff --git a/src/adloop/server.py b/src/adloop/server.py index 543cc77..a52793c 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -285,6 +285,70 @@ def get_tracking_events( ) +# --------------------------------------------------------------------------- +# Google Search Console Read Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gsc_sites() -> dict: + """List all Google Search Console properties the authenticated user can access. + + Use this first to discover which site URLs are available before running + search analytics reports. Returns the site URL and permission level for + each property. + """ + from adloop.gsc.reports import list_gsc_sites as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def run_gsc_report( + site_url: str = "", + dimensions: list[str] | None = None, + date_range_start: str = "7daysAgo", + date_range_end: str = "today", + limit: int = 100, + search_type: str = "web", + dimension_filter_groups: list[dict] | None = None, +) -> dict: + """Run a Google Search Console search analytics report. + + Returns clicks, impressions, CTR, and average position broken down by + the requested dimensions. Useful for diagnosing organic traffic drops, + finding keyword opportunities, and cross-referencing with GA4 and Ads data. + + site_url: the GSC property URL (e.g. "https://example.com/" or + "sc-domain:example.com"). Defaults to gsc.site_url in config.yaml. + dimensions: one or more of ["query", "page", "country", "device", "date"]. + Defaults to ["query"]. + date_range_start / date_range_end: ISO dates (YYYY-MM-DD) or relative + values like "7daysAgo", "30daysAgo", "today". + search_type: "web" (default), "image", "video", "news", "discover", + or "googleNews". + dimension_filter_groups: optional GSC DimensionFilterGroup list to filter + by query, page, country, or device. Example: + [{"filters": [{"dimension": "query", "operator": "contains", + "expression": "analytics"}]}] + limit: maximum rows to return (default 100, max 25000). + """ + from adloop.gsc.reports import run_gsc_report as _impl + + return _impl( + _config, + site_url=site_url, + dimensions=dimensions, + date_range_start=date_range_start, + date_range_end=date_range_end, + limit=limit, + search_type=search_type, + dimension_filter_groups=dimension_filter_groups, + ) + + # --------------------------------------------------------------------------- # Google Ads Read Tools # ---------------------------------------------------------------------------