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.
Files changed (29) hide show
  1. {adloop-0.4.3 → adloop-0.5.0}/PKG-INFO +18 -10
  2. {adloop-0.4.3 → adloop-0.5.0}/README.md +17 -9
  3. {adloop-0.4.3 → adloop-0.5.0}/pyproject.toml +1 -1
  4. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/__init__.py +1 -1
  5. adloop-0.5.0/src/adloop/ads/client.py +98 -0
  6. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/forecast.py +115 -1
  7. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/read.py +94 -0
  8. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/write.py +130 -0
  9. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/server.py +120 -0
  10. adloop-0.4.3/src/adloop/ads/client.py +0 -41
  11. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/currency.py +0 -0
  14. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/gaql.py +0 -0
  15. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ads/pmax.py +0 -0
  16. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/auth.py +0 -0
  17. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/bundled_credentials.json +0 -0
  18. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/cli.py +0 -0
  19. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/config.py +0 -0
  20. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/crossref.py +0 -0
  21. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/__init__.py +0 -0
  22. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/client.py +0 -0
  23. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/reports.py +0 -0
  24. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/ga4/tracking.py +0 -0
  25. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/__init__.py +0 -0
  26. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/audit.py +0 -0
  27. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/guards.py +0 -0
  28. {adloop-0.4.3 → adloop-0.5.0}/src/adloop/safety/preview.py +0 -0
  29. {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.4.3
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 38 Tools
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 existing negative keywords for a campaign or all campaigns |
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 to reduce wasted spend |
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 — 38 tool registrations with safety annotations
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 38 Tools
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 existing negative keywords for a campaign or all campaigns |
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 to reduce wasted spend |
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 — 38 tool registrations with safety annotations
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`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.4.3"
3
+ version = "0.5.0"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.4.3"
5
+ __version__ = "0.5.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -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 forecast metrics."""
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