Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
2 changes: 2 additions & 0 deletions src/adloop/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
10 changes: 10 additions & 0 deletions src/adloop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down
Empty file added src/adloop/gsc/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions src/adloop/gsc/client.py
Original file line number Diff line number Diff line change
@@ -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)
139 changes: 139 additions & 0 deletions src/adloop/gsc/reports.py
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions src/adloop/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down