adloop 0.4.3__tar.gz → 0.5.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.4.3 → adloop-0.5.0}/PKG-INFO +18 -10
- {adloop-0.4.3 → adloop-0.5.0}/README.md +17 -9
- {adloop-0.4.3 → adloop-0.5.0}/pyproject.toml +1 -1
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/__init__.py +1 -1
- adloop-0.5.0/src/adloop/ads/client.py +98 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/forecast.py +115 -1
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/read.py +94 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/write.py +130 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/server.py +120 -0
- adloop-0.4.3/src/adloop/ads/client.py +0 -41
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/__main__.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/auth.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/cli.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/config.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/crossref.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.4.3 → adloop-0.5.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.5.0
|
|
4
4
|
Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -66,7 +66,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
66
66
|
|
|
67
67
|
The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
|
|
68
68
|
|
|
69
|
-
## All
|
|
69
|
+
## All 43 Tools
|
|
70
70
|
|
|
71
71
|
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
|
|
72
72
|
|
|
@@ -94,7 +94,10 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
94
94
|
| `get_ad_performance` | Ad copy analysis — headlines, descriptions, CTR |
|
|
95
95
|
| `get_keyword_performance` | Keywords — quality scores, competitive metrics |
|
|
96
96
|
| `get_search_terms` | What users actually searched before clicking |
|
|
97
|
-
| `get_negative_keywords` | List
|
|
97
|
+
| `get_negative_keywords` | List direct campaign-level negative keywords |
|
|
98
|
+
| `get_negative_keyword_lists` | List all shared negative keyword lists (SharedSets) — names, IDs, status, keyword count |
|
|
99
|
+
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
|
|
100
|
+
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
|
|
98
101
|
| `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
|
|
99
102
|
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
|
|
100
103
|
| `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
|
|
@@ -123,6 +126,7 @@ These tools call both APIs internally and return unified results with auto-gener
|
|
|
123
126
|
|
|
124
127
|
| Tool | What It Does |
|
|
125
128
|
|------|-------------|
|
|
129
|
+
| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
|
|
126
130
|
| `estimate_budget` | Forecast clicks, impressions, and cost for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
|
|
127
131
|
|
|
128
132
|
### Google Ads Write Tools
|
|
@@ -140,7 +144,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
140
144
|
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
141
145
|
| `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
|
|
142
146
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
143
|
-
| `add_negative_keywords` | Propose negative keywords
|
|
147
|
+
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
148
|
+
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
144
149
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
145
150
|
| `enable_entity` | Re-enable a paused entity |
|
|
146
151
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -154,7 +159,7 @@ AdLoop ships with orchestration rules that teach the AI *how* to combine these t
|
|
|
154
159
|
- **Claude Code**: `.claude/rules/adloop.md` (synced from Cursor rules via `scripts/sync-rules.py`)
|
|
155
160
|
|
|
156
161
|
The rules include:
|
|
157
|
-
- **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, tracking validation, budget planning, landing page analysis)
|
|
162
|
+
- **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, keyword discovery, tracking validation, budget planning, landing page analysis)
|
|
158
163
|
- **GAQL quick reference** with syntax, common queries, and gotchas
|
|
159
164
|
- **Safety rules** including Broad Match + Manual CPC prevention and pre-write validation
|
|
160
165
|
- **Ad copy character limit guidance** (30-char headlines are shorter than you think)
|
|
@@ -322,6 +327,7 @@ Ask your AI assistant things like:
|
|
|
322
327
|
- *"Draft a new responsive search ad for my main campaign."*
|
|
323
328
|
- *"Which landing pages get paid traffic but don't convert?"*
|
|
324
329
|
- *"Is my tracking set up correctly? Compare my codebase events against GA4."*
|
|
330
|
+
- *"What keywords should I target for [product]? Find ideas and estimate the budget."*
|
|
325
331
|
- *"How much budget would I need for these keywords in Germany?"*
|
|
326
332
|
- *"Create a new search campaign for [product feature] with a €20/day budget."*
|
|
327
333
|
|
|
@@ -347,7 +353,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
347
353
|
```
|
|
348
354
|
src/adloop/
|
|
349
355
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
350
|
-
├── server.py # FastMCP server —
|
|
356
|
+
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
351
357
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
352
358
|
├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
|
|
353
359
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -358,12 +364,12 @@ src/adloop/
|
|
|
358
364
|
│ ├── reports.py # Account summaries, reports, realtime
|
|
359
365
|
│ └── tracking.py # Event discovery
|
|
360
366
|
├── ads/
|
|
361
|
-
│ ├── client.py # Google Ads API client (version-pinned)
|
|
367
|
+
│ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
|
|
362
368
|
│ ├── gaql.py # GAQL query execution with human-readable error parsing
|
|
363
|
-
│ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
|
|
369
|
+
│ ├── read.py # Campaign, ad, keyword, search term, negative keyword, shared sets, recommendations, audience reads
|
|
364
370
|
│ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
|
|
365
371
|
│ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
|
|
366
|
-
│ └── forecast.py # Budget estimation via Keyword Planner API
|
|
372
|
+
│ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
|
|
367
373
|
└── safety/
|
|
368
374
|
├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
|
|
369
375
|
├── preview.py # Change plans and previews
|
|
@@ -378,7 +384,9 @@ What's been shipped and what's next:
|
|
|
378
384
|
- ~~Google Ads read + write tools with safety layer~~ ✓
|
|
379
385
|
- ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
|
|
380
386
|
- ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
|
|
381
|
-
- ~~Budget estimation via Keyword Planner~~ ✓
|
|
387
|
+
- ~~Budget estimation + keyword discovery via Keyword Planner~~ ✓
|
|
388
|
+
- ~~Shared negative keyword lists (SharedSet API)~~ ✓
|
|
389
|
+
- ~~Retry/backoff for API rate limits~~ ✓
|
|
382
390
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
383
391
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
384
392
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
@@ -42,7 +42,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
42
42
|
|
|
43
43
|
The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
|
|
44
44
|
|
|
45
|
-
## All
|
|
45
|
+
## All 43 Tools
|
|
46
46
|
|
|
47
47
|
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
|
|
48
48
|
|
|
@@ -70,7 +70,10 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
70
70
|
| `get_ad_performance` | Ad copy analysis — headlines, descriptions, CTR |
|
|
71
71
|
| `get_keyword_performance` | Keywords — quality scores, competitive metrics |
|
|
72
72
|
| `get_search_terms` | What users actually searched before clicking |
|
|
73
|
-
| `get_negative_keywords` | List
|
|
73
|
+
| `get_negative_keywords` | List direct campaign-level negative keywords |
|
|
74
|
+
| `get_negative_keyword_lists` | List all shared negative keyword lists (SharedSets) — names, IDs, status, keyword count |
|
|
75
|
+
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
|
|
76
|
+
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
|
|
74
77
|
| `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
|
|
75
78
|
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
|
|
76
79
|
| `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
|
|
@@ -99,6 +102,7 @@ These tools call both APIs internally and return unified results with auto-gener
|
|
|
99
102
|
|
|
100
103
|
| Tool | What It Does |
|
|
101
104
|
|------|-------------|
|
|
105
|
+
| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
|
|
102
106
|
| `estimate_budget` | Forecast clicks, impressions, and cost for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
|
|
103
107
|
|
|
104
108
|
### Google Ads Write Tools
|
|
@@ -116,7 +120,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
116
120
|
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
117
121
|
| `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
|
|
118
122
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
119
|
-
| `add_negative_keywords` | Propose negative keywords
|
|
123
|
+
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
124
|
+
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
120
125
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
121
126
|
| `enable_entity` | Re-enable a paused entity |
|
|
122
127
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -130,7 +135,7 @@ AdLoop ships with orchestration rules that teach the AI *how* to combine these t
|
|
|
130
135
|
- **Claude Code**: `.claude/rules/adloop.md` (synced from Cursor rules via `scripts/sync-rules.py`)
|
|
131
136
|
|
|
132
137
|
The rules include:
|
|
133
|
-
- **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, tracking validation, budget planning, landing page analysis)
|
|
138
|
+
- **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, keyword discovery, tracking validation, budget planning, landing page analysis)
|
|
134
139
|
- **GAQL quick reference** with syntax, common queries, and gotchas
|
|
135
140
|
- **Safety rules** including Broad Match + Manual CPC prevention and pre-write validation
|
|
136
141
|
- **Ad copy character limit guidance** (30-char headlines are shorter than you think)
|
|
@@ -298,6 +303,7 @@ Ask your AI assistant things like:
|
|
|
298
303
|
- *"Draft a new responsive search ad for my main campaign."*
|
|
299
304
|
- *"Which landing pages get paid traffic but don't convert?"*
|
|
300
305
|
- *"Is my tracking set up correctly? Compare my codebase events against GA4."*
|
|
306
|
+
- *"What keywords should I target for [product]? Find ideas and estimate the budget."*
|
|
301
307
|
- *"How much budget would I need for these keywords in Germany?"*
|
|
302
308
|
- *"Create a new search campaign for [product feature] with a €20/day budget."*
|
|
303
309
|
|
|
@@ -323,7 +329,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
323
329
|
```
|
|
324
330
|
src/adloop/
|
|
325
331
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
326
|
-
├── server.py # FastMCP server —
|
|
332
|
+
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
327
333
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
328
334
|
├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
|
|
329
335
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -334,12 +340,12 @@ src/adloop/
|
|
|
334
340
|
│ ├── reports.py # Account summaries, reports, realtime
|
|
335
341
|
│ └── tracking.py # Event discovery
|
|
336
342
|
├── ads/
|
|
337
|
-
│ ├── client.py # Google Ads API client (version-pinned)
|
|
343
|
+
│ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
|
|
338
344
|
│ ├── gaql.py # GAQL query execution with human-readable error parsing
|
|
339
|
-
│ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
|
|
345
|
+
│ ├── read.py # Campaign, ad, keyword, search term, negative keyword, shared sets, recommendations, audience reads
|
|
340
346
|
│ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
|
|
341
347
|
│ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
|
|
342
|
-
│ └── forecast.py # Budget estimation via Keyword Planner API
|
|
348
|
+
│ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
|
|
343
349
|
└── safety/
|
|
344
350
|
├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
|
|
345
351
|
├── preview.py # Change plans and previews
|
|
@@ -354,7 +360,9 @@ What's been shipped and what's next:
|
|
|
354
360
|
- ~~Google Ads read + write tools with safety layer~~ ✓
|
|
355
361
|
- ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
|
|
356
362
|
- ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
|
|
357
|
-
- ~~Budget estimation via Keyword Planner~~ ✓
|
|
363
|
+
- ~~Budget estimation + keyword discovery via Keyword Planner~~ ✓
|
|
364
|
+
- ~~Shared negative keyword lists (SharedSet API)~~ ✓
|
|
365
|
+
- ~~Retry/backoff for API rate limits~~ ✓
|
|
358
366
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
359
367
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
360
368
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Google Ads API client wrapper — thin layer over the google-ads library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
_T = TypeVar("_T")
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
13
|
+
|
|
14
|
+
from adloop.config import AdLoopConfig
|
|
15
|
+
|
|
16
|
+
# Pin the API version so library upgrades don't silently break field names,
|
|
17
|
+
# enum values, or mutate operation structures. Bump this deliberately when
|
|
18
|
+
# migrating to a new API version — never let it float to the library default.
|
|
19
|
+
GOOGLE_ADS_API_VERSION = "v23"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
|
|
23
|
+
"""Return an authenticated Google Ads API client pinned to a specific API version."""
|
|
24
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
25
|
+
|
|
26
|
+
from adloop.auth import get_ads_credentials
|
|
27
|
+
|
|
28
|
+
credentials = get_ads_credentials(config)
|
|
29
|
+
|
|
30
|
+
client_config = {
|
|
31
|
+
"developer_token": config.ads.developer_token,
|
|
32
|
+
"use_proto_plus": True,
|
|
33
|
+
"version": GOOGLE_ADS_API_VERSION,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if config.ads.login_customer_id:
|
|
37
|
+
client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
|
|
38
|
+
|
|
39
|
+
client = GoogleAdsClient(credentials=credentials, **client_config)
|
|
40
|
+
return client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_customer_id(customer_id: str) -> str:
|
|
44
|
+
"""Strip dashes from customer ID for API calls (123-456-7890 -> 1234567890)."""
|
|
45
|
+
return customer_id.replace("-", "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_rate_limit_error(exc: Exception) -> bool:
|
|
49
|
+
"""Return True if the exception is a 429 / RESOURCE_EXHAUSTED rate-limit error.
|
|
50
|
+
|
|
51
|
+
Checks the gRPC status code on GoogleAdsException first (reliable),
|
|
52
|
+
then falls back to string matching for non-gRPC exceptions.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
from google.ads.googleads.errors import GoogleAdsException
|
|
56
|
+
|
|
57
|
+
if isinstance(exc, GoogleAdsException):
|
|
58
|
+
from grpc import StatusCode
|
|
59
|
+
|
|
60
|
+
return exc.error.code() == StatusCode.RESOURCE_EXHAUSTED
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
msg = str(exc).upper()
|
|
65
|
+
return (
|
|
66
|
+
"RESOURCE_EXHAUSTED" in msg
|
|
67
|
+
or "RATE_LIMIT" in msg
|
|
68
|
+
or "QUOTA_EXCEEDED" in msg
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call_with_retry(
|
|
73
|
+
fn: Callable[..., _T],
|
|
74
|
+
/,
|
|
75
|
+
*args: Any,
|
|
76
|
+
max_attempts: int = 4,
|
|
77
|
+
base_delay: float = 1.0,
|
|
78
|
+
**kwargs: Any,
|
|
79
|
+
) -> _T:
|
|
80
|
+
"""Call fn(*args, **kwargs) with exponential backoff on 429 / RESOURCE_EXHAUSTED.
|
|
81
|
+
|
|
82
|
+
Retries up to max_attempts times (default 4 — i.e. 3 retries after the
|
|
83
|
+
initial attempt). Each wait is base_delay * 2^attempt seconds plus up to
|
|
84
|
+
1 second of random jitter to avoid thundering-herd.
|
|
85
|
+
|
|
86
|
+
All other exceptions are re-raised immediately without retrying.
|
|
87
|
+
|
|
88
|
+
Note: uses time.sleep (blocking). This is safe because FastMCP runs sync
|
|
89
|
+
tool functions in a thread executor — only the worker thread blocks.
|
|
90
|
+
"""
|
|
91
|
+
for attempt in range(max_attempts):
|
|
92
|
+
try:
|
|
93
|
+
return fn(*args, **kwargs)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
if not _is_rate_limit_error(exc) or attempt == max_attempts - 1:
|
|
96
|
+
raise
|
|
97
|
+
delay = base_delay * (2**attempt) + random.uniform(0, 1)
|
|
98
|
+
time.sleep(delay)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Budget estimation via Google Ads Keyword Planner
|
|
1
|
+
"""Budget estimation and keyword discovery via Google Ads Keyword Planner."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -154,3 +154,117 @@ def estimate_budget(
|
|
|
154
154
|
"keywords_used": len([kw for kw in keywords if kw.get("text")]),
|
|
155
155
|
"insights": insights,
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_COMPETITION_LABELS = {0: "UNSPECIFIED", 1: "LOW", 2: "MEDIUM", 3: "HIGH"}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def discover_keywords(
|
|
163
|
+
config: AdLoopConfig,
|
|
164
|
+
*,
|
|
165
|
+
seed_keywords: list[str] = [], # noqa: B006 — mutable default required for MCP JSON schema (array, not anyOf)
|
|
166
|
+
url: str = "",
|
|
167
|
+
geo_target_id: str = "2276",
|
|
168
|
+
language_id: str = "1000",
|
|
169
|
+
page_size: int = 50,
|
|
170
|
+
customer_id: str = "",
|
|
171
|
+
) -> dict:
|
|
172
|
+
"""Discover new keyword ideas using Google Ads Keyword Planner.
|
|
173
|
+
|
|
174
|
+
Mirrors the "Discover new keywords" workflow in the Keyword Planner UI:
|
|
175
|
+
- Start with keywords: provide seed_keywords (one or more terms)
|
|
176
|
+
- Start with a website: provide url (a landing page or full site URL)
|
|
177
|
+
- Both together: keywords + url for more targeted ideas
|
|
178
|
+
|
|
179
|
+
Returns keyword ideas with avg monthly searches, competition level,
|
|
180
|
+
and top-of-page bid range.
|
|
181
|
+
|
|
182
|
+
seed_keywords: list of seed terms, e.g. ["running shoes", "trail running"]
|
|
183
|
+
url: a page or site URL to extract keyword ideas from
|
|
184
|
+
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
185
|
+
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
186
|
+
page_size: max number of keyword ideas to return (default 50, max 1000)
|
|
187
|
+
"""
|
|
188
|
+
from adloop.ads.client import call_with_retry, get_ads_client, normalize_customer_id
|
|
189
|
+
|
|
190
|
+
seed_keywords = list(seed_keywords)
|
|
191
|
+
if not seed_keywords and not url:
|
|
192
|
+
return {"error": "Provide at least one of: seed_keywords or url"}
|
|
193
|
+
|
|
194
|
+
client = get_ads_client(config)
|
|
195
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
196
|
+
googleads_service = client.get_service("GoogleAdsService")
|
|
197
|
+
kp_service = client.get_service("KeywordPlanIdeaService")
|
|
198
|
+
|
|
199
|
+
request = client.get_type("GenerateKeywordIdeasRequest")
|
|
200
|
+
request.customer_id = cid
|
|
201
|
+
request.language = googleads_service.language_constant_path(language_id)
|
|
202
|
+
request.geo_target_constants.append(
|
|
203
|
+
googleads_service.geo_target_constant_path(geo_target_id)
|
|
204
|
+
)
|
|
205
|
+
request.keyword_plan_network = (
|
|
206
|
+
client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
|
|
207
|
+
)
|
|
208
|
+
request.page_size = min(max(1, page_size), 1000)
|
|
209
|
+
|
|
210
|
+
if seed_keywords and url:
|
|
211
|
+
request.keyword_and_url_seed.url = url
|
|
212
|
+
request.keyword_and_url_seed.keywords.extend(seed_keywords)
|
|
213
|
+
elif seed_keywords:
|
|
214
|
+
request.keyword_seed.keywords.extend(seed_keywords)
|
|
215
|
+
else:
|
|
216
|
+
request.url_seed.url = url
|
|
217
|
+
|
|
218
|
+
response = call_with_retry(kp_service.generate_keyword_ideas, request=request)
|
|
219
|
+
|
|
220
|
+
ideas = []
|
|
221
|
+
for idea in response:
|
|
222
|
+
metrics = idea.keyword_idea_metrics
|
|
223
|
+
avg_monthly = getattr(metrics, "avg_monthly_searches", None)
|
|
224
|
+
competition_value = getattr(metrics, "competition", 0)
|
|
225
|
+
competition_label = _COMPETITION_LABELS.get(int(competition_value), "UNSPECIFIED")
|
|
226
|
+
competition_index = getattr(metrics, "competition_index", None)
|
|
227
|
+
low_bid_micros = getattr(metrics, "low_top_of_page_bid_micros", None)
|
|
228
|
+
high_bid_micros = getattr(metrics, "high_top_of_page_bid_micros", None)
|
|
229
|
+
|
|
230
|
+
ideas.append({
|
|
231
|
+
"keyword": idea.text,
|
|
232
|
+
"avg_monthly_searches": int(avg_monthly) if avg_monthly else None,
|
|
233
|
+
"competition": competition_label,
|
|
234
|
+
"competition_index": int(competition_index) if competition_index else None,
|
|
235
|
+
"low_top_of_page_bid": round(low_bid_micros / 1_000_000, 2) if low_bid_micros else None,
|
|
236
|
+
"high_top_of_page_bid": round(high_bid_micros / 1_000_000, 2) if high_bid_micros else None,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
# Sort by avg monthly searches descending (None last)
|
|
240
|
+
ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
|
|
241
|
+
|
|
242
|
+
insights = []
|
|
243
|
+
if ideas:
|
|
244
|
+
high_competition = [i for i in ideas if i["competition"] == "HIGH"]
|
|
245
|
+
low_competition = [i for i in ideas if i["competition"] == "LOW"]
|
|
246
|
+
if high_competition:
|
|
247
|
+
insights.append(
|
|
248
|
+
f"{len(high_competition)} high-competition keyword(s) — expect "
|
|
249
|
+
f"higher CPCs and harder positioning."
|
|
250
|
+
)
|
|
251
|
+
if low_competition:
|
|
252
|
+
insights.append(
|
|
253
|
+
f"{len(low_competition)} low-competition keyword(s) — good "
|
|
254
|
+
f"opportunities for early traction at lower cost."
|
|
255
|
+
)
|
|
256
|
+
with_volume = [i for i in ideas if i["avg_monthly_searches"]]
|
|
257
|
+
if with_volume:
|
|
258
|
+
top = with_volume[0]
|
|
259
|
+
insights.append(
|
|
260
|
+
f"Highest-volume idea: '{top['keyword']}' with ~{top['avg_monthly_searches']:,} "
|
|
261
|
+
f"avg monthly searches."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"keyword_ideas": ideas,
|
|
266
|
+
"total_ideas": len(ideas),
|
|
267
|
+
"seed_keywords": seed_keywords,
|
|
268
|
+
"seed_url": url,
|
|
269
|
+
"insights": insights,
|
|
270
|
+
}
|
|
@@ -222,6 +222,100 @@ def get_negative_keywords(
|
|
|
222
222
|
return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
|
|
223
223
|
|
|
224
224
|
|
|
225
|
+
def get_negative_keyword_lists(
|
|
226
|
+
config: AdLoopConfig,
|
|
227
|
+
*,
|
|
228
|
+
customer_id: str = "",
|
|
229
|
+
) -> dict:
|
|
230
|
+
"""List all shared negative keyword lists (SharedSets) in the account.
|
|
231
|
+
|
|
232
|
+
Returns each list's ID, name, status, and keyword count. Use this before
|
|
233
|
+
calling propose_negative_keyword_list to check whether a suitable list
|
|
234
|
+
already exists and only needs attaching to a new campaign.
|
|
235
|
+
"""
|
|
236
|
+
from adloop.ads.gaql import execute_query
|
|
237
|
+
|
|
238
|
+
query = """
|
|
239
|
+
SELECT shared_set.id, shared_set.name, shared_set.status,
|
|
240
|
+
shared_set.member_count, shared_set.resource_name
|
|
241
|
+
FROM shared_set
|
|
242
|
+
WHERE shared_set.type = 'NEGATIVE_KEYWORDS'
|
|
243
|
+
AND shared_set.status != 'REMOVED'
|
|
244
|
+
ORDER BY shared_set.name
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
rows = execute_query(config, customer_id, query)
|
|
248
|
+
return {"negative_keyword_lists": rows, "total_lists": len(rows)}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_negative_keyword_list_keywords(
|
|
252
|
+
config: AdLoopConfig,
|
|
253
|
+
*,
|
|
254
|
+
customer_id: str = "",
|
|
255
|
+
shared_set_id: str = "",
|
|
256
|
+
) -> dict:
|
|
257
|
+
"""List the keywords inside a shared negative keyword list.
|
|
258
|
+
|
|
259
|
+
shared_set_id: the numeric ID from get_negative_keyword_lists
|
|
260
|
+
(shared_set.id field).
|
|
261
|
+
"""
|
|
262
|
+
from adloop.ads.gaql import execute_query
|
|
263
|
+
|
|
264
|
+
if not shared_set_id:
|
|
265
|
+
return {"error": "shared_set_id is required"}
|
|
266
|
+
if not shared_set_id.isdigit():
|
|
267
|
+
return {"error": "shared_set_id must be a numeric ID"}
|
|
268
|
+
|
|
269
|
+
query = f"""
|
|
270
|
+
SELECT shared_criterion.keyword.text,
|
|
271
|
+
shared_criterion.keyword.match_type,
|
|
272
|
+
shared_criterion.type,
|
|
273
|
+
shared_set.id, shared_set.name
|
|
274
|
+
FROM shared_criterion
|
|
275
|
+
WHERE shared_set.id = {shared_set_id}
|
|
276
|
+
ORDER BY shared_criterion.keyword.text
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
rows = execute_query(config, customer_id, query)
|
|
280
|
+
return {
|
|
281
|
+
"keywords": rows,
|
|
282
|
+
"total_keywords": len(rows),
|
|
283
|
+
"shared_set_id": shared_set_id,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_negative_keyword_list_campaigns(
|
|
288
|
+
config: AdLoopConfig,
|
|
289
|
+
*,
|
|
290
|
+
customer_id: str = "",
|
|
291
|
+
shared_set_id: str = "",
|
|
292
|
+
) -> dict:
|
|
293
|
+
"""List which campaigns a shared negative keyword list is attached to.
|
|
294
|
+
|
|
295
|
+
shared_set_id: the numeric ID from get_negative_keyword_lists
|
|
296
|
+
(shared_set.id field). Omit to return all list-to-campaign attachments.
|
|
297
|
+
"""
|
|
298
|
+
from adloop.ads.gaql import execute_query
|
|
299
|
+
|
|
300
|
+
shared_set_filter = ""
|
|
301
|
+
if shared_set_id:
|
|
302
|
+
if not shared_set_id.isdigit():
|
|
303
|
+
return {"error": "shared_set_id must be a numeric ID"}
|
|
304
|
+
shared_set_filter = f"AND shared_set.id = {shared_set_id}"
|
|
305
|
+
|
|
306
|
+
query = f"""
|
|
307
|
+
SELECT campaign.id, campaign.name, campaign.status,
|
|
308
|
+
shared_set.id, shared_set.name
|
|
309
|
+
FROM campaign_shared_set
|
|
310
|
+
WHERE campaign_shared_set.status != 'REMOVED'
|
|
311
|
+
{shared_set_filter}
|
|
312
|
+
ORDER BY shared_set.name, campaign.name
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
rows = execute_query(config, customer_id, query)
|
|
316
|
+
return {"attachments": rows, "total_attachments": len(rows)}
|
|
317
|
+
|
|
318
|
+
|
|
225
319
|
def get_recommendations(
|
|
226
320
|
config: AdLoopConfig,
|
|
227
321
|
*,
|
|
@@ -341,6 +341,61 @@ def add_negative_keywords(
|
|
|
341
341
|
return plan.to_preview()
|
|
342
342
|
|
|
343
343
|
|
|
344
|
+
def propose_negative_keyword_list(
|
|
345
|
+
config: AdLoopConfig,
|
|
346
|
+
*,
|
|
347
|
+
customer_id: str = "",
|
|
348
|
+
campaign_id: str = "",
|
|
349
|
+
list_name: str = "",
|
|
350
|
+
keywords: list[str] | None = None,
|
|
351
|
+
match_type: str = "EXACT",
|
|
352
|
+
) -> dict:
|
|
353
|
+
"""Draft a shared negative keyword list and attach it to a campaign — returns PREVIEW.
|
|
354
|
+
|
|
355
|
+
Creates a reusable negative keyword list (SharedSet) with the given keywords
|
|
356
|
+
and links it to the campaign. Unlike add_negative_keywords, the list can later
|
|
357
|
+
be reused across multiple campaigns.
|
|
358
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
359
|
+
"""
|
|
360
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
361
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
check_blocked_operation("create_negative_keyword_list", config.safety)
|
|
365
|
+
except SafetyViolation as e:
|
|
366
|
+
return {"error": str(e)}
|
|
367
|
+
|
|
368
|
+
keywords = keywords or []
|
|
369
|
+
match_type = match_type.upper()
|
|
370
|
+
|
|
371
|
+
errors = []
|
|
372
|
+
if not campaign_id:
|
|
373
|
+
errors.append("campaign_id is required")
|
|
374
|
+
if not list_name:
|
|
375
|
+
errors.append("list_name is required")
|
|
376
|
+
if not keywords:
|
|
377
|
+
errors.append("At least one keyword is required")
|
|
378
|
+
if match_type not in _VALID_MATCH_TYPES:
|
|
379
|
+
errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
|
|
380
|
+
if errors:
|
|
381
|
+
return {"error": "Validation failed", "details": errors}
|
|
382
|
+
|
|
383
|
+
plan = ChangePlan(
|
|
384
|
+
operation="create_negative_keyword_list",
|
|
385
|
+
entity_type="negative_keyword_list",
|
|
386
|
+
entity_id=campaign_id,
|
|
387
|
+
customer_id=customer_id,
|
|
388
|
+
changes={
|
|
389
|
+
"campaign_id": campaign_id,
|
|
390
|
+
"list_name": list_name,
|
|
391
|
+
"keywords": keywords,
|
|
392
|
+
"match_type": match_type,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
store_plan(plan)
|
|
396
|
+
return plan.to_preview()
|
|
397
|
+
|
|
398
|
+
|
|
344
399
|
def update_ad_group(
|
|
345
400
|
config: AdLoopConfig,
|
|
346
401
|
*,
|
|
@@ -1670,6 +1725,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
|
1670
1725
|
"create_responsive_search_ad": _apply_create_rsa,
|
|
1671
1726
|
"add_keywords": _apply_add_keywords,
|
|
1672
1727
|
"add_negative_keywords": _apply_add_negative_keywords,
|
|
1728
|
+
"create_negative_keyword_list": _apply_create_negative_keyword_list,
|
|
1673
1729
|
"pause_entity": _apply_status_change,
|
|
1674
1730
|
"enable_entity": _apply_status_change,
|
|
1675
1731
|
"remove_entity": _apply_remove,
|
|
@@ -2462,3 +2518,77 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict:
|
|
|
2462
2518
|
client.enums.AssetFieldTypeEnum.SITELINK,
|
|
2463
2519
|
populate,
|
|
2464
2520
|
)
|
|
2521
|
+
|
|
2522
|
+
|
|
2523
|
+
def _apply_create_negative_keyword_list(
|
|
2524
|
+
client: object, cid: str, changes: dict
|
|
2525
|
+
) -> dict:
|
|
2526
|
+
"""Create a shared negative keyword list and attach it to a campaign.
|
|
2527
|
+
|
|
2528
|
+
Executes three sequential API calls. If step 2 or 3 fails, the result
|
|
2529
|
+
includes partial_failure info with the SharedSet resource name so the
|
|
2530
|
+
caller can clean up or retry the remaining steps.
|
|
2531
|
+
"""
|
|
2532
|
+
shared_set_resource = None
|
|
2533
|
+
|
|
2534
|
+
# 1. Create the SharedSet
|
|
2535
|
+
shared_set_service = client.get_service("SharedSetService")
|
|
2536
|
+
ss_op = client.get_type("SharedSetOperation")
|
|
2537
|
+
shared_set = ss_op.create
|
|
2538
|
+
shared_set.name = changes["list_name"]
|
|
2539
|
+
shared_set.type_ = client.enums.SharedSetTypeEnum.NEGATIVE_KEYWORDS
|
|
2540
|
+
ss_response = shared_set_service.mutate_shared_sets(
|
|
2541
|
+
customer_id=cid, operations=[ss_op]
|
|
2542
|
+
)
|
|
2543
|
+
shared_set_resource = ss_response.results[0].resource_name
|
|
2544
|
+
|
|
2545
|
+
# 2. Add keywords to the list
|
|
2546
|
+
try:
|
|
2547
|
+
sc_service = client.get_service("SharedCriterionService")
|
|
2548
|
+
sc_ops = []
|
|
2549
|
+
for kw_text in changes["keywords"]:
|
|
2550
|
+
sc_op = client.get_type("SharedCriterionOperation")
|
|
2551
|
+
criterion = sc_op.create
|
|
2552
|
+
criterion.shared_set = shared_set_resource
|
|
2553
|
+
criterion.keyword.text = kw_text
|
|
2554
|
+
criterion.keyword.match_type = getattr(
|
|
2555
|
+
client.enums.KeywordMatchTypeEnum, changes["match_type"]
|
|
2556
|
+
)
|
|
2557
|
+
sc_ops.append(sc_op)
|
|
2558
|
+
sc_service.mutate_shared_criteria(customer_id=cid, operations=sc_ops)
|
|
2559
|
+
except Exception as exc:
|
|
2560
|
+
return {
|
|
2561
|
+
"partial_failure": True,
|
|
2562
|
+
"shared_set_resource": shared_set_resource,
|
|
2563
|
+
"completed_steps": ["create_shared_set"],
|
|
2564
|
+
"failed_step": "add_keywords",
|
|
2565
|
+
"error": _extract_error_message(exc),
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
# 3. Attach the list to the campaign
|
|
2569
|
+
try:
|
|
2570
|
+
css_service = client.get_service("CampaignSharedSetService")
|
|
2571
|
+
css_op = client.get_type("CampaignSharedSetOperation")
|
|
2572
|
+
campaign_shared_set = css_op.create
|
|
2573
|
+
campaign_shared_set.campaign = client.get_service(
|
|
2574
|
+
"CampaignService"
|
|
2575
|
+
).campaign_path(cid, changes["campaign_id"])
|
|
2576
|
+
campaign_shared_set.shared_set = shared_set_resource
|
|
2577
|
+
css_response = css_service.mutate_campaign_shared_sets(
|
|
2578
|
+
customer_id=cid, operations=[css_op]
|
|
2579
|
+
)
|
|
2580
|
+
except Exception as exc:
|
|
2581
|
+
return {
|
|
2582
|
+
"partial_failure": True,
|
|
2583
|
+
"shared_set_resource": shared_set_resource,
|
|
2584
|
+
"keyword_count": len(changes["keywords"]),
|
|
2585
|
+
"completed_steps": ["create_shared_set", "add_keywords"],
|
|
2586
|
+
"failed_step": "attach_to_campaign",
|
|
2587
|
+
"error": _extract_error_message(exc),
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
return {
|
|
2591
|
+
"shared_set_resource": shared_set_resource,
|
|
2592
|
+
"campaign_shared_set_resource": css_response.results[0].resource_name,
|
|
2593
|
+
"keyword_count": len(changes["keywords"]),
|
|
2594
|
+
}
|
|
@@ -393,6 +393,61 @@ def get_negative_keywords(
|
|
|
393
393
|
)
|
|
394
394
|
|
|
395
395
|
|
|
396
|
+
@mcp.tool(annotations=_READONLY)
|
|
397
|
+
@_safe
|
|
398
|
+
def get_negative_keyword_lists(
|
|
399
|
+
customer_id: str = "",
|
|
400
|
+
) -> dict:
|
|
401
|
+
"""List all shared negative keyword lists (SharedSets) in the account.
|
|
402
|
+
|
|
403
|
+
Returns each list's ID, name, status, and keyword count. Always call
|
|
404
|
+
this before propose_negative_keyword_list to avoid creating duplicates —
|
|
405
|
+
a suitable list may already exist and just need attaching to a campaign.
|
|
406
|
+
"""
|
|
407
|
+
from adloop.ads.read import get_negative_keyword_lists as _impl
|
|
408
|
+
|
|
409
|
+
return _impl(_config, customer_id=customer_id or _config.ads.customer_id)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@mcp.tool(annotations=_READONLY)
|
|
413
|
+
@_safe
|
|
414
|
+
def get_negative_keyword_list_keywords(
|
|
415
|
+
shared_set_id: str,
|
|
416
|
+
customer_id: str = "",
|
|
417
|
+
) -> dict:
|
|
418
|
+
"""List the keywords inside a shared negative keyword list.
|
|
419
|
+
|
|
420
|
+
shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
|
|
421
|
+
"""
|
|
422
|
+
from adloop.ads.read import get_negative_keyword_list_keywords as _impl
|
|
423
|
+
|
|
424
|
+
return _impl(
|
|
425
|
+
_config,
|
|
426
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
427
|
+
shared_set_id=shared_set_id,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@mcp.tool(annotations=_READONLY)
|
|
432
|
+
@_safe
|
|
433
|
+
def get_negative_keyword_list_campaigns(
|
|
434
|
+
shared_set_id: str = "",
|
|
435
|
+
customer_id: str = "",
|
|
436
|
+
) -> dict:
|
|
437
|
+
"""List which campaigns a shared negative keyword list is attached to.
|
|
438
|
+
|
|
439
|
+
shared_set_id: numeric ID from get_negative_keyword_lists. Omit to see
|
|
440
|
+
all list-to-campaign attachments across the account.
|
|
441
|
+
"""
|
|
442
|
+
from adloop.ads.read import get_negative_keyword_list_campaigns as _impl
|
|
443
|
+
|
|
444
|
+
return _impl(
|
|
445
|
+
_config,
|
|
446
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
447
|
+
shared_set_id=shared_set_id,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
396
451
|
# ---------------------------------------------------------------------------
|
|
397
452
|
# Google Ads — Recommendations, Performance Max & Audience Tools
|
|
398
453
|
# ---------------------------------------------------------------------------
|
|
@@ -896,6 +951,34 @@ def add_negative_keywords(
|
|
|
896
951
|
)
|
|
897
952
|
|
|
898
953
|
|
|
954
|
+
@mcp.tool(annotations=_WRITE)
|
|
955
|
+
@_safe
|
|
956
|
+
def propose_negative_keyword_list(
|
|
957
|
+
campaign_id: str,
|
|
958
|
+
list_name: str,
|
|
959
|
+
keywords: list[str],
|
|
960
|
+
customer_id: str = "",
|
|
961
|
+
match_type: str = "EXACT",
|
|
962
|
+
) -> dict:
|
|
963
|
+
"""Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW.
|
|
964
|
+
|
|
965
|
+
Creates a reusable negative keyword list that can later be applied to multiple
|
|
966
|
+
campaigns, unlike add_negative_keywords which adds directly to one campaign.
|
|
967
|
+
match_type: "EXACT", "PHRASE", or "BROAD"
|
|
968
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
969
|
+
"""
|
|
970
|
+
from adloop.ads.write import propose_negative_keyword_list as _impl
|
|
971
|
+
|
|
972
|
+
return _impl(
|
|
973
|
+
_config,
|
|
974
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
975
|
+
campaign_id=campaign_id,
|
|
976
|
+
list_name=list_name,
|
|
977
|
+
keywords=keywords,
|
|
978
|
+
match_type=match_type,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
899
982
|
@mcp.tool(annotations=_WRITE)
|
|
900
983
|
@_safe
|
|
901
984
|
def update_ad_group(
|
|
@@ -1212,3 +1295,40 @@ def estimate_budget(
|
|
|
1212
1295
|
forecast_days=forecast_days,
|
|
1213
1296
|
customer_id=customer_id or _config.ads.customer_id,
|
|
1214
1297
|
)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
@mcp.tool(annotations=_READONLY)
|
|
1301
|
+
@_safe
|
|
1302
|
+
def discover_keywords(
|
|
1303
|
+
seed_keywords: list[str] = [], # noqa: B006 — mutable default required for MCP JSON schema
|
|
1304
|
+
url: str = "",
|
|
1305
|
+
geo_target_id: str = "2276",
|
|
1306
|
+
language_id: str = "1000",
|
|
1307
|
+
page_size: int = 50,
|
|
1308
|
+
customer_id: str = "",
|
|
1309
|
+
) -> dict:
|
|
1310
|
+
"""Discover new keyword ideas using Google Ads Keyword Planner.
|
|
1311
|
+
|
|
1312
|
+
Mirrors the "Discover new keywords" UI in Keyword Planner:
|
|
1313
|
+
- Start with keywords: pass seed_keywords (e.g. ["running shoes"])
|
|
1314
|
+
- Start with a website: pass url (e.g. "https://example.com/products")
|
|
1315
|
+
- Both together: keywords + url for more targeted ideas
|
|
1316
|
+
|
|
1317
|
+
Returns keyword ideas sorted by avg monthly search volume, with
|
|
1318
|
+
competition level (LOW/MEDIUM/HIGH) and top-of-page bid range.
|
|
1319
|
+
|
|
1320
|
+
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
1321
|
+
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
1322
|
+
page_size: max keyword ideas to return (default 50, max 1000)
|
|
1323
|
+
"""
|
|
1324
|
+
from adloop.ads.forecast import discover_keywords as _impl
|
|
1325
|
+
|
|
1326
|
+
return _impl(
|
|
1327
|
+
_config,
|
|
1328
|
+
seed_keywords=seed_keywords,
|
|
1329
|
+
url=url,
|
|
1330
|
+
geo_target_id=geo_target_id,
|
|
1331
|
+
language_id=language_id,
|
|
1332
|
+
page_size=page_size,
|
|
1333
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
1334
|
+
)
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""Google Ads API client wrapper — thin layer over the google-ads library."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
if TYPE_CHECKING:
|
|
8
|
-
from google.ads.googleads.client import GoogleAdsClient
|
|
9
|
-
|
|
10
|
-
from adloop.config import AdLoopConfig
|
|
11
|
-
|
|
12
|
-
# Pin the API version so library upgrades don't silently break field names,
|
|
13
|
-
# enum values, or mutate operation structures. Bump this deliberately when
|
|
14
|
-
# migrating to a new API version — never let it float to the library default.
|
|
15
|
-
GOOGLE_ADS_API_VERSION = "v23"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
|
|
19
|
-
"""Return an authenticated Google Ads API client pinned to a specific API version."""
|
|
20
|
-
from google.ads.googleads.client import GoogleAdsClient
|
|
21
|
-
|
|
22
|
-
from adloop.auth import get_ads_credentials
|
|
23
|
-
|
|
24
|
-
credentials = get_ads_credentials(config)
|
|
25
|
-
|
|
26
|
-
client_config = {
|
|
27
|
-
"developer_token": config.ads.developer_token,
|
|
28
|
-
"use_proto_plus": True,
|
|
29
|
-
"version": GOOGLE_ADS_API_VERSION,
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if config.ads.login_customer_id:
|
|
33
|
-
client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
|
|
34
|
-
|
|
35
|
-
client = GoogleAdsClient(credentials=credentials, **client_config)
|
|
36
|
-
return client
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def normalize_customer_id(customer_id: str) -> str:
|
|
40
|
-
"""Strip dashes from customer ID for API calls (123-456-7890 -> 1234567890)."""
|
|
41
|
-
return customer_id.replace("-", "")
|
|
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
|