adloop 0.16.0__tar.gz → 0.17.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adloop-0.16.0 → adloop-0.17.0}/PKG-INFO +5 -1
- {adloop-0.16.0 → adloop-0.17.0}/README.md +4 -0
- {adloop-0.16.0 → adloop-0.17.0}/pyproject.toml +1 -1
- {adloop-0.16.0 → adloop-0.17.0}/pyproject.toml.orig +1 -1
- adloop-0.17.0/src/adloop/ads/brands.py +201 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/read.py +14 -2
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/validate_only.py +41 -14
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/reports.py +21 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/adloop.md +5 -1
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/server.py +94 -7
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/__main__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/auth.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/cli.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/config.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/crossref.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/runtime.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.16.0 → adloop-0.17.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -132,8 +132,12 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
132
132
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
133
133
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
134
134
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
135
|
+
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
136
|
+
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
135
137
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
136
138
|
|
|
139
|
+
> **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
|
|
140
|
+
|
|
137
141
|
> **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
|
|
138
142
|
|
|
139
143
|
### Cross-Reference Tools (GA4 + Ads Combined)
|
|
@@ -105,8 +105,12 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
105
105
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
106
106
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
107
107
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
108
|
+
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
109
|
+
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
108
110
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
109
111
|
|
|
112
|
+
> **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
|
|
113
|
+
|
|
110
114
|
> **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
|
|
111
115
|
|
|
112
116
|
### Cross-Reference Tools (GA4 + Ads Combined)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Google Ads brand tools — resolve brand names to Google's brand entities.
|
|
2
|
+
|
|
3
|
+
Backs the brand picker of the Google Ads UI: ``BrandSuggestionService``
|
|
4
|
+
matches a free-text name against Google's brand knowledge graph and answers
|
|
5
|
+
with the canonical brand ID, display name, state, and associated URLs.
|
|
6
|
+
|
|
7
|
+
The ID is what brand targeting actually needs. Brand criteria (brand lists /
|
|
8
|
+
``BRAND_HINT``) reference the Commercial Knowledge Graph ID, not the display
|
|
9
|
+
name — resolving the name first is what makes "steer the account towards
|
|
10
|
+
brand X" possible at all. Read-only: no account state is touched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from adloop.config import AdLoopConfig
|
|
20
|
+
|
|
21
|
+
# SuggestBrands resolves a single prefix per round trip, and a bulk check is
|
|
22
|
+
# therefore one API call per name. The cap keeps a single tool call inside the
|
|
23
|
+
# MCP host's timeout while covering the usual audit batch (a campaign's brand
|
|
24
|
+
# shortlist, a competitor shortlist, ...). Longer lists are split by the caller.
|
|
25
|
+
MAX_BRAND_BATCH = 25
|
|
26
|
+
|
|
27
|
+
_SENTINEL_STATES = {"UNSPECIFIED", "UNKNOWN"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def suggest_brands(
|
|
31
|
+
config: AdLoopConfig,
|
|
32
|
+
*,
|
|
33
|
+
brand_prefix: str,
|
|
34
|
+
selected_brand_ids: list[str] | None = None,
|
|
35
|
+
customer_id: str = "",
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Resolve one brand name to the brands Google recognizes for it.
|
|
38
|
+
|
|
39
|
+
``selected_brand_ids`` mirrors the API field of the same purpose: IDs the
|
|
40
|
+
caller already picked can be handed back so Google keeps them in the
|
|
41
|
+
suggestion set while the prefix narrows.
|
|
42
|
+
"""
|
|
43
|
+
prefix = (brand_prefix or "").strip()
|
|
44
|
+
if not prefix:
|
|
45
|
+
return {"error": "brand_prefix is required — pass the brand name to look up."}
|
|
46
|
+
|
|
47
|
+
cid = customer_id or config.ads.customer_id
|
|
48
|
+
brands = _fetch_brand_suggestions(
|
|
49
|
+
config,
|
|
50
|
+
customer_id=cid,
|
|
51
|
+
brand_prefix=prefix,
|
|
52
|
+
selected_brand_ids=selected_brand_ids or [],
|
|
53
|
+
)
|
|
54
|
+
return {
|
|
55
|
+
"customer_id": _digits(cid),
|
|
56
|
+
"brand_prefix": prefix,
|
|
57
|
+
"brand_count": len(brands),
|
|
58
|
+
"brands": brands,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_brand_names(
|
|
63
|
+
config: AdLoopConfig,
|
|
64
|
+
*,
|
|
65
|
+
brand_names: list[str],
|
|
66
|
+
customer_id: str = "",
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Check several brand names against Google's brand knowledge graph.
|
|
69
|
+
|
|
70
|
+
One ``SuggestBrands`` call per name. Names Google does not know come back
|
|
71
|
+
as ``status: "no_match"`` with an empty candidate list — that is a normal
|
|
72
|
+
answer, not an error. An API failure aborts the whole batch and surfaces
|
|
73
|
+
as the regular structured error, so an expired token cannot hide behind a
|
|
74
|
+
half-filled result list.
|
|
75
|
+
"""
|
|
76
|
+
names = _clean_brand_names(brand_names)
|
|
77
|
+
if not names:
|
|
78
|
+
return {"error": "brand_names is required — pass the brand names to check."}
|
|
79
|
+
if len(names) > MAX_BRAND_BATCH:
|
|
80
|
+
return {
|
|
81
|
+
"error": (
|
|
82
|
+
f"Too many brand names in one call ({len(names)}). "
|
|
83
|
+
f"SuggestBrands answers one prefix per request — split into "
|
|
84
|
+
f"batches of at most {MAX_BRAND_BATCH}."
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cid = customer_id or config.ads.customer_id
|
|
89
|
+
results: list[dict] = []
|
|
90
|
+
for name in names:
|
|
91
|
+
brands = _fetch_brand_suggestions(
|
|
92
|
+
config, customer_id=cid, brand_prefix=name, selected_brand_ids=[]
|
|
93
|
+
)
|
|
94
|
+
best, exact = _best_match(name, brands)
|
|
95
|
+
results.append(
|
|
96
|
+
{
|
|
97
|
+
"query": name,
|
|
98
|
+
"status": "matched" if best else "no_match",
|
|
99
|
+
"exact_match": exact,
|
|
100
|
+
"brand": best,
|
|
101
|
+
"candidates": brands,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
matched = sum(1 for r in results if r["status"] == "matched")
|
|
106
|
+
return {
|
|
107
|
+
"customer_id": _digits(cid),
|
|
108
|
+
"checked": len(results),
|
|
109
|
+
"matched": matched,
|
|
110
|
+
"no_match": len(results) - matched,
|
|
111
|
+
"results": results,
|
|
112
|
+
"note": (
|
|
113
|
+
"brand.id is the Commercial Knowledge Graph ID that brand "
|
|
114
|
+
"criteria (brand lists / BRAND_HINT) target. Match exact names "
|
|
115
|
+
"first; candidates are Google's own suggestions, not guarantees."
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Internals
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fetch_brand_suggestions(
|
|
126
|
+
config: AdLoopConfig,
|
|
127
|
+
*,
|
|
128
|
+
customer_id: str,
|
|
129
|
+
brand_prefix: str,
|
|
130
|
+
selected_brand_ids: list[str],
|
|
131
|
+
) -> list[dict[str, Any]]:
|
|
132
|
+
"""One ``BrandSuggestionService.SuggestBrands`` round trip, flattened."""
|
|
133
|
+
from adloop.ads.client import call_with_retry, get_ads_client, normalize_customer_id
|
|
134
|
+
|
|
135
|
+
client = get_ads_client(config)
|
|
136
|
+
service = client.get_service("BrandSuggestionService")
|
|
137
|
+
request = client.get_type("SuggestBrandsRequest")
|
|
138
|
+
request.customer_id = normalize_customer_id(customer_id)
|
|
139
|
+
request.brand_prefix = brand_prefix
|
|
140
|
+
for brand_id in selected_brand_ids:
|
|
141
|
+
request.selected_brands.append(str(brand_id))
|
|
142
|
+
|
|
143
|
+
response = call_with_retry(service.suggest_brands, request=request)
|
|
144
|
+
|
|
145
|
+
brand_state = client.enums.BrandStateEnum
|
|
146
|
+
brands: list[dict[str, Any]] = []
|
|
147
|
+
for suggestion in response.brands:
|
|
148
|
+
state = brand_state(suggestion.state).name
|
|
149
|
+
brands.append(
|
|
150
|
+
{
|
|
151
|
+
"id": suggestion.id,
|
|
152
|
+
"name": suggestion.name,
|
|
153
|
+
"state": state,
|
|
154
|
+
"urls": list(suggestion.urls),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return brands
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _clean_brand_names(brand_names: list[str] | None) -> list[str]:
|
|
161
|
+
"""Trim, drop blanks, and de-duplicate while keeping the caller's order."""
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
names: list[str] = []
|
|
164
|
+
for raw in brand_names or []:
|
|
165
|
+
name = (raw or "").strip()
|
|
166
|
+
if not name or name.casefold() in seen:
|
|
167
|
+
continue
|
|
168
|
+
seen.add(name.casefold())
|
|
169
|
+
names.append(name)
|
|
170
|
+
return names
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _best_match(query: str, brands: list[dict[str, Any]]) -> tuple[dict | None, bool]:
|
|
174
|
+
"""Pick the suggestion that best answers *query*.
|
|
175
|
+
|
|
176
|
+
Ranking is deliberately shallow: an exact name match wins, then brands in
|
|
177
|
+
a usable state, then Google's own order. Anything deeper would imply a
|
|
178
|
+
confidence the API does not provide.
|
|
179
|
+
"""
|
|
180
|
+
if not brands:
|
|
181
|
+
return None, False
|
|
182
|
+
|
|
183
|
+
normalized = _normalize(query)
|
|
184
|
+
ranked = sorted(
|
|
185
|
+
brands,
|
|
186
|
+
key=lambda brand: (
|
|
187
|
+
0 if _normalize(brand["name"]) == normalized else 1,
|
|
188
|
+
0 if brand["state"] not in _SENTINEL_STATES else 1,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
best = ranked[0]
|
|
192
|
+
return best, _normalize(best["name"]) == normalized
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _normalize(name: str) -> str:
|
|
196
|
+
"""Case- and punctuation-insensitive form used for exact-match checks."""
|
|
197
|
+
return re.sub(r"[\W_]+", "", (name or "").casefold())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _digits(customer_id: str) -> str:
|
|
201
|
+
return (customer_id or "").replace("-", "")
|
|
@@ -190,6 +190,7 @@ def get_ad_performance(
|
|
|
190
190
|
"ad_group": r.get("ad_group.name"),
|
|
191
191
|
"headlines": headlines,
|
|
192
192
|
"descriptions": descriptions,
|
|
193
|
+
"final_urls": r.get("ad_group_ad.ad.final_urls") or [],
|
|
193
194
|
})
|
|
194
195
|
|
|
195
196
|
single_ad_groups = [
|
|
@@ -205,7 +206,6 @@ def get_ad_performance(
|
|
|
205
206
|
if k not in (
|
|
206
207
|
"ad_group_ad.ad.responsive_search_ad.headlines",
|
|
207
208
|
"ad_group_ad.ad.responsive_search_ad.descriptions",
|
|
208
|
-
"ad_group_ad.ad.final_urls",
|
|
209
209
|
)
|
|
210
210
|
}
|
|
211
211
|
slim["headline_count"] = _asset_count(
|
|
@@ -228,6 +228,12 @@ def get_ad_performance(
|
|
|
228
228
|
f"Google cannot rotate or optimize with a single creative."
|
|
229
229
|
)
|
|
230
230
|
|
|
231
|
+
# Landing-page work needs every ad's URL, not just the top ten rows'.
|
|
232
|
+
landing_pages: dict[str, int] = {}
|
|
233
|
+
for r in rows:
|
|
234
|
+
for url in r.get("ad_group_ad.ad.final_urls") or []:
|
|
235
|
+
landing_pages[url] = landing_pages.get(url, 0) + 1
|
|
236
|
+
|
|
231
237
|
top = [_slim(r) for r in rows[:10]]
|
|
232
238
|
return {
|
|
233
239
|
"compact": True,
|
|
@@ -237,9 +243,14 @@ def get_ad_performance(
|
|
|
237
243
|
"ads_top_spend": top,
|
|
238
244
|
"incomplete_rsas": incomplete_rsas[:10],
|
|
239
245
|
"single_ad_ad_groups": single_ad_groups[:10],
|
|
246
|
+
"landing_pages": [
|
|
247
|
+
{"final_url": url, "ads": count}
|
|
248
|
+
for url, count in sorted(landing_pages.items(), key=lambda item: -item[1])
|
|
249
|
+
],
|
|
240
250
|
"insights": insights,
|
|
241
251
|
"note": _compact_note(len(top), len(rows), "get_ad_performance")
|
|
242
|
-
+ " Compact rows replace full headline/description lists with counts
|
|
252
|
+
+ " Compact rows replace full headline/description lists with counts;"
|
|
253
|
+
" landing_pages lists every final URL across all ads.",
|
|
243
254
|
}
|
|
244
255
|
|
|
245
256
|
|
|
@@ -480,6 +491,7 @@ def get_negative_keywords(
|
|
|
480
491
|
campaign_criterion.criterion_id
|
|
481
492
|
FROM campaign_criterion
|
|
482
493
|
WHERE campaign_criterion.negative = TRUE
|
|
494
|
+
AND campaign_criterion.type = 'KEYWORD'
|
|
483
495
|
AND campaign_criterion.status != 'REMOVED'
|
|
484
496
|
{campaign_filter}
|
|
485
497
|
ORDER BY campaign.name
|
|
@@ -10,6 +10,11 @@ Validate-only responses carry no results, so each validated call answers the
|
|
|
10
10
|
apply code with placeholder resource names. A later call that references one
|
|
11
11
|
(a plan whose second step uses what the first would have created) cannot be
|
|
12
12
|
validated meaningfully; it is skipped and counted instead of sent.
|
|
13
|
+
|
|
14
|
+
The wrapper fails closed: only path helpers, reads, ``mutate*`` and
|
|
15
|
+
``upload*`` calls (both sent validate-only) reach the real service. Any other
|
|
16
|
+
method raises, so a write path added later cannot slip past a dry run and
|
|
17
|
+
change an account for real.
|
|
13
18
|
"""
|
|
14
19
|
|
|
15
20
|
from __future__ import annotations
|
|
@@ -18,7 +23,8 @@ from types import SimpleNamespace
|
|
|
18
23
|
|
|
19
24
|
PLACEHOLDER = "adloop-validate-only"
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
# Methods that never change an account and may run for real in a dry run.
|
|
27
|
+
_READ_METHODS = frozenset({"search", "search_stream"})
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
class ValidateOnlyFailure(Exception):
|
|
@@ -52,9 +58,14 @@ class _ValidateOnlyService:
|
|
|
52
58
|
self._service = service
|
|
53
59
|
|
|
54
60
|
def __getattr__(self, attr: str) -> object:
|
|
61
|
+
if attr in _READ_METHODS or attr.endswith("_path") or attr.startswith("parse_"):
|
|
62
|
+
return getattr(self._service, attr)
|
|
63
|
+
if not (attr.startswith("mutate") or attr.startswith("upload")):
|
|
64
|
+
raise ValidateOnlyFailure(
|
|
65
|
+
f"{self._name}.{attr} has no validate-only mode, so a dry run "
|
|
66
|
+
"cannot check it without changing the account. Refusing."
|
|
67
|
+
)
|
|
55
68
|
target = getattr(self._service, attr)
|
|
56
|
-
if not attr.startswith("mutate"):
|
|
57
|
-
return target
|
|
58
69
|
|
|
59
70
|
def validate(request: object = None, **kwargs: object) -> object:
|
|
60
71
|
if request is None:
|
|
@@ -78,35 +89,51 @@ class _ValidateOnlyService:
|
|
|
78
89
|
def _build_request(self, method: str, kwargs: dict) -> object:
|
|
79
90
|
request = self._owner._client.get_type(_request_type(self._name, method))
|
|
80
91
|
for key, value in kwargs.items():
|
|
81
|
-
if key in
|
|
92
|
+
if key in _LIST_FIELDS:
|
|
82
93
|
getattr(request, key).extend(value)
|
|
83
94
|
else:
|
|
84
95
|
setattr(request, key, value)
|
|
85
96
|
return request
|
|
86
97
|
|
|
87
98
|
|
|
99
|
+
_LIST_FIELDS = ("operations", "mutate_operations", "conversions")
|
|
100
|
+
|
|
101
|
+
|
|
88
102
|
def _request_type(service_name: str, method: str) -> str:
|
|
89
|
-
"""``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest
|
|
103
|
+
"""``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest``,
|
|
104
|
+
``upload_click_conversions`` -> ``UploadClickConversionsRequest``."""
|
|
90
105
|
if method == "mutate":
|
|
91
106
|
return "Mutate" + service_name.removesuffix("Service") + "Request"
|
|
92
|
-
|
|
93
|
-
return
|
|
107
|
+
verb, _, rest = method.partition("_")
|
|
108
|
+
return verb.capitalize() + "".join(word.capitalize() for word in rest.split("_")) + "Request"
|
|
94
109
|
|
|
95
110
|
|
|
96
111
|
def _operations_of(request: object) -> object:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
for field in _LIST_FIELDS:
|
|
113
|
+
if hasattr(request, field):
|
|
114
|
+
return getattr(request, field)
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _PlaceholderResult:
|
|
119
|
+
"""A MutateOperationResponse stand-in: every ``*_result`` field
|
|
120
|
+
(``asset_result``, ``campaign_asset_result``, ...) carries the placeholder,
|
|
121
|
+
whichever one the apply code reads."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, name: str) -> None:
|
|
124
|
+
self._name = name
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, attr: str) -> object:
|
|
127
|
+
if attr.endswith("_result"):
|
|
128
|
+
return SimpleNamespace(resource_name=self._name)
|
|
129
|
+
raise AttributeError(attr)
|
|
100
130
|
|
|
101
131
|
|
|
102
132
|
def _placeholder_response(customer_id: str, count: int, googleads_mutate: bool) -> object:
|
|
103
133
|
names = [f"customers/{customer_id}/{PLACEHOLDER}/{i}" for i in range(count)]
|
|
104
134
|
if googleads_mutate:
|
|
105
135
|
return SimpleNamespace(
|
|
106
|
-
mutate_operation_responses=[
|
|
107
|
-
SimpleNamespace(**{_RESPONSE_FIELD_FOR_SERVICE: SimpleNamespace(resource_name=n)})
|
|
108
|
-
for n in names
|
|
109
|
-
],
|
|
136
|
+
mutate_operation_responses=[_PlaceholderResult(n) for n in names],
|
|
110
137
|
partial_failure_error=None,
|
|
111
138
|
)
|
|
112
139
|
return SimpleNamespace(
|
|
@@ -36,6 +36,27 @@ def get_account_summaries(config: AdLoopConfig) -> dict:
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def first_property(summaries: dict) -> str:
|
|
40
|
+
"""The first property in a get_account_summaries() result, or ""."""
|
|
41
|
+
for account in summaries.get("accounts", []):
|
|
42
|
+
for prop in account.get("properties", []):
|
|
43
|
+
if prop.get("property"):
|
|
44
|
+
return prop["property"]
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def probe_data_api(config: AdLoopConfig, property_name: str) -> None:
|
|
49
|
+
"""Raise if the GA4 Data API cannot serve this property.
|
|
50
|
+
|
|
51
|
+
A metadata lookup is the cheapest Data API call: it touches no report
|
|
52
|
+
quota, yet fails with SERVICE_DISABLED exactly when every report would.
|
|
53
|
+
"""
|
|
54
|
+
from adloop.ga4.client import get_data_client
|
|
55
|
+
|
|
56
|
+
name = property_name if property_name.startswith("properties/") else f"properties/{property_name}"
|
|
57
|
+
get_data_client(config).get_metadata(name=f"{name}/metadata")
|
|
58
|
+
|
|
59
|
+
|
|
39
60
|
def run_ga4_report(
|
|
40
61
|
config: AdLoopConfig,
|
|
41
62
|
*,
|
|
@@ -14,6 +14,8 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
14
14
|
|------|-------------|----------------|
|
|
15
15
|
| `health_check` | First thing to run when tools are failing — tests OAuth token, GA4, Ads, and (when configured) Reddit Ads connectivity | (none) |
|
|
16
16
|
|
|
17
|
+
**GA4 has two surfaces in health_check:** `ga4_admin` (listing properties) and `ga4_data` (every report). `ga4_data: error` with `SERVICE_DISABLED` means the Google Analytics Data API is not enabled in the Google Cloud project — tell the user to enable it; it is not a permissions or consent problem.
|
|
18
|
+
|
|
17
19
|
**If health_check reports Google auth errors:** Tell the user to delete `~/.adloop/token.json` and re-run any tool to trigger re-authorization. If tokens keep expiring weekly, the GCP consent screen needs to be published from "Testing" to "In production". **Reddit auth errors** (`REDDIT_INVALID_GRANT`, `REDDIT_NOT_CONNECTED`) are fixed by re-running the Reddit step of `adloop init` (self-hosted) or reconnecting under Settings → Reddit Ads (AdLoop Cloud); the Google token is unrelated.
|
|
18
20
|
|
|
19
21
|
### GA4 Read Tools
|
|
@@ -44,6 +46,8 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
44
46
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) |
|
|
45
47
|
| `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) |
|
|
46
48
|
| `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` |
|
|
49
|
+
| `suggest_brands` | Resolve one brand name to the brands Google recognizes — brand ID, name, state, URLs. The ID is what brand criteria target, so resolve here before building any brand list | `brand_prefix` (required), `selected_brand_ids` (optional, IDs already picked) |
|
|
50
|
+
| `check_brand_names` | Triage a shortlist of brand names at once: matched vs. unknown, plus each brand's ID | `brand_names` (required, max 25 per call) |
|
|
47
51
|
| `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) |
|
|
48
52
|
|
|
49
53
|
**Return format notes:**
|
|
@@ -55,7 +59,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
55
59
|
- `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations.
|
|
56
60
|
- `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings.
|
|
57
61
|
- `get_demographic_targeting` returns an empty list when no demographics have been excluded or narrowed — that is the DEFAULT state (Google serves to all segments). Each criterion includes a composite `remove_id` (`adGroupId~criterionId` or `campaignId~criterionId`) that can be passed straight to `remove_entity`.
|
|
58
|
-
- **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) instead of every row — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.
|
|
62
|
+
- **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) instead of every row; compact `get_ad_performance` keeps `final_urls` on its rows and adds `landing_pages` (every final URL across all ads with its ad count), so landing-page checks need no extra GAQL — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.
|
|
59
63
|
|
|
60
64
|
### Cross-Reference Tools (GA4 + Ads combined)
|
|
61
65
|
|
|
@@ -421,15 +421,10 @@ def health_check() -> dict:
|
|
|
421
421
|
except ImportError:
|
|
422
422
|
pass
|
|
423
423
|
|
|
424
|
-
|
|
425
|
-
from adloop.ga4.reports import get_account_summaries as _ga4_test
|
|
426
|
-
|
|
427
|
-
result = _ga4_test(current_config())
|
|
428
|
-
status["ga4"] = "ok"
|
|
429
|
-
status["ga4_properties"] = result.get("total_properties", 0)
|
|
430
|
-
except Exception as e:
|
|
424
|
+
def _ga4_failed(surface: str, e: Exception) -> None:
|
|
431
425
|
parsed = _structured_error("health_check", e)
|
|
432
426
|
status["ga4"] = "error"
|
|
427
|
+
status[surface] = "error"
|
|
433
428
|
status["ga4_error"] = parsed["error"]
|
|
434
429
|
if "hint" in parsed:
|
|
435
430
|
status["ga4_hint"] = parsed["hint"]
|
|
@@ -438,6 +433,32 @@ def health_check() -> dict:
|
|
|
438
433
|
if "details" in parsed:
|
|
439
434
|
status["ga4_error_details"] = parsed["details"]
|
|
440
435
|
|
|
436
|
+
# Two surfaces: the Admin API lists properties, the Data API serves every
|
|
437
|
+
# report. A project can have one enabled and not the other, so "ok" means
|
|
438
|
+
# both answered.
|
|
439
|
+
try:
|
|
440
|
+
from adloop.ga4.reports import get_account_summaries as _ga4_test
|
|
441
|
+
|
|
442
|
+
result = _ga4_test(current_config())
|
|
443
|
+
status["ga4_admin"] = "ok"
|
|
444
|
+
status["ga4_properties"] = result.get("total_properties", 0)
|
|
445
|
+
except Exception as e:
|
|
446
|
+
_ga4_failed("ga4_admin", e)
|
|
447
|
+
else:
|
|
448
|
+
from adloop.ga4.reports import first_property, probe_data_api
|
|
449
|
+
|
|
450
|
+
prop = current_config().ga4.property_id or first_property(result)
|
|
451
|
+
if not prop:
|
|
452
|
+
status["ga4"] = "ok"
|
|
453
|
+
status["ga4_data"] = "not_checked"
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
probe_data_api(current_config(), prop)
|
|
457
|
+
status["ga4"] = "ok"
|
|
458
|
+
status["ga4_data"] = "ok"
|
|
459
|
+
except Exception as e:
|
|
460
|
+
_ga4_failed("ga4_data", e)
|
|
461
|
+
|
|
441
462
|
try:
|
|
442
463
|
from adloop.ads.gaql import execute_query
|
|
443
464
|
|
|
@@ -3291,6 +3312,72 @@ def discover_keywords(
|
|
|
3291
3312
|
)
|
|
3292
3313
|
|
|
3293
3314
|
|
|
3315
|
+
# ---------------------------------------------------------------------------
|
|
3316
|
+
# Google Ads — Brand Tools
|
|
3317
|
+
# ---------------------------------------------------------------------------
|
|
3318
|
+
|
|
3319
|
+
|
|
3320
|
+
@mcp.tool(title="Suggest brands", annotations=_READONLY, tags={"ads"})
|
|
3321
|
+
@_safe
|
|
3322
|
+
def suggest_brands(
|
|
3323
|
+
brand_prefix: str,
|
|
3324
|
+
selected_brand_ids: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
|
|
3325
|
+
customer_id: str = "",
|
|
3326
|
+
) -> dict:
|
|
3327
|
+
"""Resolve a brand name to the brands Google recognizes for it.
|
|
3328
|
+
|
|
3329
|
+
Mirrors the brand picker in the Google Ads UI
|
|
3330
|
+
(BrandSuggestionService.SuggestBrands): pass a free-text name such as
|
|
3331
|
+
"EscapeGame München" or "NoWayOut" and get back the matching brands with
|
|
3332
|
+
their ID, display name, state, and associated URLs.
|
|
3333
|
+
|
|
3334
|
+
The returned brand ID is the Commercial Knowledge Graph ID. Brand
|
|
3335
|
+
criteria — brand lists, brand exclusions — target that ID, not a display
|
|
3336
|
+
name, so resolve the name here before building any brand list.
|
|
3337
|
+
|
|
3338
|
+
selected_brand_ids: IDs already picked, handed back so Google keeps them
|
|
3339
|
+
in the suggestion set while the prefix narrows. Optional.
|
|
3340
|
+
"""
|
|
3341
|
+
from adloop.ads.brands import suggest_brands as _impl
|
|
3342
|
+
|
|
3343
|
+
return _impl(
|
|
3344
|
+
current_config(),
|
|
3345
|
+
brand_prefix=brand_prefix,
|
|
3346
|
+
selected_brand_ids=selected_brand_ids,
|
|
3347
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
3348
|
+
)
|
|
3349
|
+
|
|
3350
|
+
|
|
3351
|
+
@mcp.tool(title="Check brand names", annotations=_READONLY, tags={"ads"})
|
|
3352
|
+
@_safe
|
|
3353
|
+
def check_brand_names(
|
|
3354
|
+
brand_names: _StrList,
|
|
3355
|
+
customer_id: str = "",
|
|
3356
|
+
) -> dict:
|
|
3357
|
+
"""Check a list of brand names against Google's brand knowledge graph.
|
|
3358
|
+
|
|
3359
|
+
One SuggestBrands call per name, for the case where a shortlist has to be
|
|
3360
|
+
triaged instead of a single name resolved: which of these brands does
|
|
3361
|
+
Google know at all, and what is the ID of each?
|
|
3362
|
+
|
|
3363
|
+
Every entry answers with status "matched" (with a best-match brand plus
|
|
3364
|
+
all candidates) or "no_match" (empty candidate list — a normal answer,
|
|
3365
|
+
not an error). exact_match marks a candidate whose name matches the query
|
|
3366
|
+
apart from case and punctuation; anything else is a Google suggestion,
|
|
3367
|
+
not a guarantee.
|
|
3368
|
+
|
|
3369
|
+
At most 25 names per call — the API resolves one prefix per request, so
|
|
3370
|
+
split longer lists.
|
|
3371
|
+
"""
|
|
3372
|
+
from adloop.ads.brands import check_brand_names as _impl
|
|
3373
|
+
|
|
3374
|
+
return _impl(
|
|
3375
|
+
current_config(),
|
|
3376
|
+
brand_names=brand_names,
|
|
3377
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
3378
|
+
)
|
|
3379
|
+
|
|
3380
|
+
|
|
3294
3381
|
# ---------------------------------------------------------------------------
|
|
3295
3382
|
# Optional local-only debug tools (not shipped in git).
|
|
3296
3383
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|