adloop 0.4.2__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.2 → adloop-0.5.0}/PKG-INFO +38 -21
- {adloop-0.4.2 → adloop-0.5.0}/README.md +37 -20
- {adloop-0.4.2 → adloop-0.5.0}/pyproject.toml +1 -1
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/__init__.py +5 -1
- adloop-0.5.0/src/adloop/ads/client.py +98 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/forecast.py +115 -1
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/read.py +99 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/write.py +172 -21
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/cli.py +50 -9
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/server.py +122 -1
- adloop-0.4.2/src/adloop/ads/client.py +0 -41
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/__main__.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/auth.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/config.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/crossref.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.4.2 → 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)
|
|
@@ -224,6 +229,25 @@ The wizard:
|
|
|
224
229
|
|
|
225
230
|
- Python 3.11+
|
|
226
231
|
- A Google Ads account with an MCC (Manager Account)
|
|
232
|
+
- A Google Ads Developer Token (see below)
|
|
233
|
+
|
|
234
|
+
### Google Ads Developer Token
|
|
235
|
+
|
|
236
|
+
A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
|
|
237
|
+
|
|
238
|
+
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
239
|
+
2. In the MCC, go to **Tools & Settings → API Center**
|
|
240
|
+
3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
|
|
241
|
+
|
|
242
|
+
**Access levels** — your token's access level determines what it can do:
|
|
243
|
+
|
|
244
|
+
| Level | How to Get | What It Allows |
|
|
245
|
+
|-------|-----------|----------------|
|
|
246
|
+
| **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
|
|
247
|
+
| **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
|
|
248
|
+
| **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
|
|
249
|
+
|
|
250
|
+
> **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
|
|
227
251
|
|
|
228
252
|
### Headless Servers
|
|
229
253
|
|
|
@@ -253,17 +277,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
|
|
|
253
277
|
|
|
254
278
|
> Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
|
|
255
279
|
|
|
256
|
-
#### Step 3 —
|
|
257
|
-
|
|
258
|
-
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
259
|
-
2. In the MCC, go to **Tools & Settings → API Center**
|
|
260
|
-
3. Your **developer token** is shown there. Copy it.
|
|
261
|
-
|
|
262
|
-
Access levels:
|
|
263
|
-
- **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
|
|
264
|
-
- **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
|
|
265
|
-
|
|
266
|
-
#### Step 4 — Connect to Your Editor
|
|
280
|
+
#### Step 3 — Connect to Your Editor
|
|
267
281
|
|
|
268
282
|
**Cursor** — Add to your project's `.cursor/mcp.json`:
|
|
269
283
|
|
|
@@ -313,6 +327,7 @@ Ask your AI assistant things like:
|
|
|
313
327
|
- *"Draft a new responsive search ad for my main campaign."*
|
|
314
328
|
- *"Which landing pages get paid traffic but don't convert?"*
|
|
315
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."*
|
|
316
331
|
- *"How much budget would I need for these keywords in Germany?"*
|
|
317
332
|
- *"Create a new search campaign for [product feature] with a €20/day budget."*
|
|
318
333
|
|
|
@@ -338,7 +353,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
338
353
|
```
|
|
339
354
|
src/adloop/
|
|
340
355
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
341
|
-
├── server.py # FastMCP server —
|
|
356
|
+
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
342
357
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
343
358
|
├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
|
|
344
359
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -349,12 +364,12 @@ src/adloop/
|
|
|
349
364
|
│ ├── reports.py # Account summaries, reports, realtime
|
|
350
365
|
│ └── tracking.py # Event discovery
|
|
351
366
|
├── ads/
|
|
352
|
-
│ ├── client.py # Google Ads API client (version-pinned)
|
|
367
|
+
│ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
|
|
353
368
|
│ ├── gaql.py # GAQL query execution with human-readable error parsing
|
|
354
|
-
│ ├── 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
|
|
355
370
|
│ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
|
|
356
371
|
│ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
|
|
357
|
-
│ └── forecast.py # Budget estimation via Keyword Planner API
|
|
372
|
+
│ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
|
|
358
373
|
└── safety/
|
|
359
374
|
├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
|
|
360
375
|
├── preview.py # Change plans and previews
|
|
@@ -369,7 +384,9 @@ What's been shipped and what's next:
|
|
|
369
384
|
- ~~Google Ads read + write tools with safety layer~~ ✓
|
|
370
385
|
- ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
|
|
371
386
|
- ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
|
|
372
|
-
- ~~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~~ ✓
|
|
373
390
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
374
391
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
375
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)
|
|
@@ -200,6 +205,25 @@ The wizard:
|
|
|
200
205
|
|
|
201
206
|
- Python 3.11+
|
|
202
207
|
- A Google Ads account with an MCC (Manager Account)
|
|
208
|
+
- A Google Ads Developer Token (see below)
|
|
209
|
+
|
|
210
|
+
### Google Ads Developer Token
|
|
211
|
+
|
|
212
|
+
A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
|
|
213
|
+
|
|
214
|
+
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
215
|
+
2. In the MCC, go to **Tools & Settings → API Center**
|
|
216
|
+
3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
|
|
217
|
+
|
|
218
|
+
**Access levels** — your token's access level determines what it can do:
|
|
219
|
+
|
|
220
|
+
| Level | How to Get | What It Allows |
|
|
221
|
+
|-------|-----------|----------------|
|
|
222
|
+
| **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
|
|
223
|
+
| **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
|
|
224
|
+
| **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
|
|
225
|
+
|
|
226
|
+
> **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
|
|
203
227
|
|
|
204
228
|
### Headless Servers
|
|
205
229
|
|
|
@@ -229,17 +253,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
|
|
|
229
253
|
|
|
230
254
|
> Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
|
|
231
255
|
|
|
232
|
-
#### Step 3 —
|
|
233
|
-
|
|
234
|
-
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
235
|
-
2. In the MCC, go to **Tools & Settings → API Center**
|
|
236
|
-
3. Your **developer token** is shown there. Copy it.
|
|
237
|
-
|
|
238
|
-
Access levels:
|
|
239
|
-
- **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
|
|
240
|
-
- **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
|
|
241
|
-
|
|
242
|
-
#### Step 4 — Connect to Your Editor
|
|
256
|
+
#### Step 3 — Connect to Your Editor
|
|
243
257
|
|
|
244
258
|
**Cursor** — Add to your project's `.cursor/mcp.json`:
|
|
245
259
|
|
|
@@ -289,6 +303,7 @@ Ask your AI assistant things like:
|
|
|
289
303
|
- *"Draft a new responsive search ad for my main campaign."*
|
|
290
304
|
- *"Which landing pages get paid traffic but don't convert?"*
|
|
291
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."*
|
|
292
307
|
- *"How much budget would I need for these keywords in Germany?"*
|
|
293
308
|
- *"Create a new search campaign for [product feature] with a €20/day budget."*
|
|
294
309
|
|
|
@@ -314,7 +329,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
314
329
|
```
|
|
315
330
|
src/adloop/
|
|
316
331
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
317
|
-
├── server.py # FastMCP server —
|
|
332
|
+
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
318
333
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
319
334
|
├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
|
|
320
335
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -325,12 +340,12 @@ src/adloop/
|
|
|
325
340
|
│ ├── reports.py # Account summaries, reports, realtime
|
|
326
341
|
│ └── tracking.py # Event discovery
|
|
327
342
|
├── ads/
|
|
328
|
-
│ ├── client.py # Google Ads API client (version-pinned)
|
|
343
|
+
│ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
|
|
329
344
|
│ ├── gaql.py # GAQL query execution with human-readable error parsing
|
|
330
|
-
│ ├── 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
|
|
331
346
|
│ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
|
|
332
347
|
│ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
|
|
333
|
-
│ └── forecast.py # Budget estimation via Keyword Planner API
|
|
348
|
+
│ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
|
|
334
349
|
└── safety/
|
|
335
350
|
├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
|
|
336
351
|
├── preview.py # Change plans and previews
|
|
@@ -345,7 +360,9 @@ What's been shipped and what's next:
|
|
|
345
360
|
- ~~Google Ads read + write tools with safety layer~~ ✓
|
|
346
361
|
- ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
|
|
347
362
|
- ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
|
|
348
|
-
- ~~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~~ ✓
|
|
349
366
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
350
367
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
351
368
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.5.0"
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def main() -> None:
|
|
@@ -11,6 +11,10 @@ def main() -> None:
|
|
|
11
11
|
Routes to the setup wizard when called as ``adloop init``,
|
|
12
12
|
otherwise starts the MCP server.
|
|
13
13
|
"""
|
|
14
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
|
|
15
|
+
print(f"adloop {__version__}")
|
|
16
|
+
return
|
|
17
|
+
|
|
14
18
|
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
|
15
19
|
from adloop.cli import run_init_wizard
|
|
16
20
|
|
|
@@ -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
|
+
}
|
|
@@ -214,9 +214,108 @@ def get_negative_keywords(
|
|
|
214
214
|
"""
|
|
215
215
|
|
|
216
216
|
rows = execute_query(config, customer_id, query)
|
|
217
|
+
for row in rows:
|
|
218
|
+
cid = row.get("campaign.id")
|
|
219
|
+
crit_id = row.get("campaign_criterion.criterion_id")
|
|
220
|
+
if cid and crit_id:
|
|
221
|
+
row["resource_id"] = f"{cid}~{crit_id}"
|
|
217
222
|
return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
|
|
218
223
|
|
|
219
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
|
+
|
|
220
319
|
def get_recommendations(
|
|
221
320
|
config: AdLoopConfig,
|
|
222
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
|
*,
|
|
@@ -1626,6 +1681,35 @@ def _draft_status_change(
|
|
|
1626
1681
|
# ---------------------------------------------------------------------------
|
|
1627
1682
|
|
|
1628
1683
|
|
|
1684
|
+
_MUTATE_RESPONSE_RESULT_FIELDS = [
|
|
1685
|
+
"campaign_budget_result",
|
|
1686
|
+
"campaign_result",
|
|
1687
|
+
"ad_group_result",
|
|
1688
|
+
"ad_group_ad_result",
|
|
1689
|
+
"ad_group_criterion_result",
|
|
1690
|
+
"campaign_criterion_result",
|
|
1691
|
+
"asset_result",
|
|
1692
|
+
"campaign_asset_result",
|
|
1693
|
+
"customer_asset_result",
|
|
1694
|
+
]
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def _extract_resource_name(resp: object) -> str:
|
|
1698
|
+
"""Extract the resource_name from a MutateOperationResponse.
|
|
1699
|
+
|
|
1700
|
+
Uses direct field access instead of WhichOneof, which doesn't work on
|
|
1701
|
+
proto-plus wrapped messages returned by the google-ads library.
|
|
1702
|
+
"""
|
|
1703
|
+
for field in _MUTATE_RESPONSE_RESULT_FIELDS:
|
|
1704
|
+
try:
|
|
1705
|
+
result = getattr(resp, field, None)
|
|
1706
|
+
if result and result.resource_name:
|
|
1707
|
+
return result.resource_name
|
|
1708
|
+
except Exception:
|
|
1709
|
+
continue
|
|
1710
|
+
return ""
|
|
1711
|
+
|
|
1712
|
+
|
|
1629
1713
|
def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
1630
1714
|
"""Dispatch to the right Google Ads mutate call based on plan.operation."""
|
|
1631
1715
|
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
@@ -1641,6 +1725,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
|
1641
1725
|
"create_responsive_search_ad": _apply_create_rsa,
|
|
1642
1726
|
"add_keywords": _apply_add_keywords,
|
|
1643
1727
|
"add_negative_keywords": _apply_add_negative_keywords,
|
|
1728
|
+
"create_negative_keyword_list": _apply_create_negative_keyword_list,
|
|
1644
1729
|
"pause_entity": _apply_status_change,
|
|
1645
1730
|
"enable_entity": _apply_status_change,
|
|
1646
1731
|
"remove_entity": _apply_remove,
|
|
@@ -1818,22 +1903,20 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
|
|
|
1818
1903
|
num_geo = len(changes.get("geo_target_ids") or [])
|
|
1819
1904
|
num_lang = len(changes.get("language_ids") or [])
|
|
1820
1905
|
for i, resp in enumerate(response.mutate_operation_responses):
|
|
1821
|
-
|
|
1822
|
-
if
|
|
1823
|
-
inner = getattr(resp, resp_type)
|
|
1824
|
-
resource = getattr(inner, "resource_name", str(inner))
|
|
1906
|
+
rn = _extract_resource_name(resp)
|
|
1907
|
+
if rn:
|
|
1825
1908
|
if i == 0:
|
|
1826
|
-
results["campaign_budget"] =
|
|
1909
|
+
results["campaign_budget"] = rn
|
|
1827
1910
|
elif i == 1:
|
|
1828
|
-
results["campaign"] =
|
|
1911
|
+
results["campaign"] = rn
|
|
1829
1912
|
elif i == 2:
|
|
1830
|
-
results["ad_group"] =
|
|
1913
|
+
results["ad_group"] = rn
|
|
1831
1914
|
elif i < 3 + num_keywords:
|
|
1832
|
-
results.setdefault("keywords", []).append(
|
|
1915
|
+
results.setdefault("keywords", []).append(rn)
|
|
1833
1916
|
elif i < 3 + num_keywords + num_geo:
|
|
1834
|
-
results.setdefault("geo_targets", []).append(
|
|
1917
|
+
results.setdefault("geo_targets", []).append(rn)
|
|
1835
1918
|
else:
|
|
1836
|
-
results.setdefault("language_targets", []).append(
|
|
1919
|
+
results.setdefault("language_targets", []).append(rn)
|
|
1837
1920
|
|
|
1838
1921
|
return results
|
|
1839
1922
|
|
|
@@ -1874,14 +1957,12 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict:
|
|
|
1874
1957
|
|
|
1875
1958
|
results: dict = {}
|
|
1876
1959
|
for i, resp in enumerate(response.mutate_operation_responses):
|
|
1877
|
-
|
|
1878
|
-
if
|
|
1879
|
-
inner = getattr(resp, resp_type)
|
|
1880
|
-
resource = getattr(inner, "resource_name", str(inner))
|
|
1960
|
+
rn = _extract_resource_name(resp)
|
|
1961
|
+
if rn:
|
|
1881
1962
|
if i == 0:
|
|
1882
|
-
results["ad_group"] =
|
|
1963
|
+
results["ad_group"] = rn
|
|
1883
1964
|
else:
|
|
1884
|
-
results.setdefault("keywords", []).append(
|
|
1965
|
+
results.setdefault("keywords", []).append(rn)
|
|
1885
1966
|
|
|
1886
1967
|
return results
|
|
1887
1968
|
|
|
@@ -2039,11 +2120,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
|
|
|
2039
2120
|
|
|
2040
2121
|
results = {"updated": []}
|
|
2041
2122
|
for resp in response.mutate_operation_responses:
|
|
2042
|
-
rn = (
|
|
2043
|
-
resp.campaign_result.resource_name
|
|
2044
|
-
or resp.campaign_budget_result.resource_name
|
|
2045
|
-
or resp.campaign_criterion_result.resource_name
|
|
2046
|
-
)
|
|
2123
|
+
rn = _extract_resource_name(resp)
|
|
2047
2124
|
if rn:
|
|
2048
2125
|
results["updated"].append(rn)
|
|
2049
2126
|
return results
|
|
@@ -2441,3 +2518,77 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict:
|
|
|
2441
2518
|
client.enums.AssetFieldTypeEnum.SITELINK,
|
|
2442
2519
|
populate,
|
|
2443
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
|
+
}
|
|
@@ -294,16 +294,17 @@ def run_init_wizard() -> None:
|
|
|
294
294
|
_print()
|
|
295
295
|
|
|
296
296
|
existing_config = None
|
|
297
|
+
_original_config_backup: str | None = None
|
|
297
298
|
if _CONFIG_PATH.exists():
|
|
298
299
|
_print(f" Found existing config at {_CONFIG_PATH}")
|
|
299
300
|
if not _prompt_bool("Overwrite existing configuration?", default=False):
|
|
300
301
|
_print(" Keeping existing config. Exiting.")
|
|
301
302
|
return
|
|
303
|
+
_original_config_backup = _CONFIG_PATH.read_text()
|
|
302
304
|
try:
|
|
303
305
|
import yaml
|
|
304
306
|
|
|
305
|
-
|
|
306
|
-
existing_config = yaml.safe_load(f) or {}
|
|
307
|
+
existing_config = yaml.safe_load(_original_config_backup) or {}
|
|
307
308
|
except Exception:
|
|
308
309
|
existing_config = {}
|
|
309
310
|
|
|
@@ -364,7 +365,8 @@ def run_init_wizard() -> None:
|
|
|
364
365
|
step_num += 1
|
|
365
366
|
_step_header(step_num, "Authorization & Account Discovery")
|
|
366
367
|
|
|
367
|
-
# Write a
|
|
368
|
+
# Write a temporary config for OAuth + discovery. If the wizard is
|
|
369
|
+
# interrupted after this point, _cleanup_on_cancel restores the original.
|
|
368
370
|
_ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
|
|
369
371
|
temp_config_yaml = _generate_config_yaml(
|
|
370
372
|
project_id=project_id,
|
|
@@ -378,6 +380,40 @@ def run_init_wizard() -> None:
|
|
|
378
380
|
)
|
|
379
381
|
_CONFIG_PATH.write_text(temp_config_yaml)
|
|
380
382
|
|
|
383
|
+
# Everything below uses the temp config for OAuth and discovery.
|
|
384
|
+
# If the wizard is interrupted, restore the original config (or remove
|
|
385
|
+
# the temp) so we never leave a half-baked config on disk.
|
|
386
|
+
try:
|
|
387
|
+
_run_wizard_post_config(
|
|
388
|
+
use_bundled=use_bundled,
|
|
389
|
+
credentials_path=credentials_path,
|
|
390
|
+
project_id=project_id,
|
|
391
|
+
developer_token=developer_token,
|
|
392
|
+
login_customer_id=login_customer_id,
|
|
393
|
+
step_num=step_num,
|
|
394
|
+
_existing=_existing,
|
|
395
|
+
)
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
if _original_config_backup is not None:
|
|
398
|
+
_CONFIG_PATH.write_text(_original_config_backup)
|
|
399
|
+
elif _CONFIG_PATH.exists():
|
|
400
|
+
_CONFIG_PATH.unlink()
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _run_wizard_post_config(
|
|
405
|
+
*,
|
|
406
|
+
use_bundled: bool,
|
|
407
|
+
credentials_path: str,
|
|
408
|
+
project_id: str,
|
|
409
|
+
developer_token: str,
|
|
410
|
+
login_customer_id: str,
|
|
411
|
+
step_num: int,
|
|
412
|
+
_existing: object,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Run the wizard steps after the temp config has been written."""
|
|
415
|
+
from adloop.config import load_config
|
|
416
|
+
|
|
381
417
|
# Optional: copy custom credentials to ~/.adloop/
|
|
382
418
|
if not use_bundled and credentials_path:
|
|
383
419
|
creds_expanded = Path(credentials_path).expanduser()
|
|
@@ -395,13 +431,14 @@ def run_init_wizard() -> None:
|
|
|
395
431
|
_print()
|
|
396
432
|
oauth_ok = False
|
|
397
433
|
try:
|
|
398
|
-
from adloop.config import load_config
|
|
399
434
|
from adloop.auth import _oauth_flow
|
|
400
435
|
|
|
401
436
|
cfg = load_config(str(_CONFIG_PATH))
|
|
402
437
|
_oauth_flow(cfg)
|
|
403
438
|
_print(" ✓ OAuth token saved")
|
|
404
439
|
oauth_ok = True
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
raise
|
|
405
442
|
except Exception as exc:
|
|
406
443
|
_print(f" ✗ OAuth failed: {exc}")
|
|
407
444
|
_print(" You can retry later — any AdLoop tool call will trigger auth.")
|
|
@@ -427,10 +464,12 @@ def run_init_wizard() -> None:
|
|
|
427
464
|
else:
|
|
428
465
|
_print(" No GA4 properties found. Enter manually:")
|
|
429
466
|
property_id = _prompt_property_id()
|
|
467
|
+
except KeyboardInterrupt:
|
|
468
|
+
raise
|
|
430
469
|
except Exception as exc:
|
|
431
470
|
_print(f" Could not auto-discover GA4 properties: {exc}")
|
|
432
471
|
property_id = _prompt_property_id(
|
|
433
|
-
default=_existing("ga4", "property_id"),
|
|
472
|
+
default=_existing("ga4", "property_id"), # type: ignore[operator]
|
|
434
473
|
)
|
|
435
474
|
else:
|
|
436
475
|
step_num += 1
|
|
@@ -439,7 +478,7 @@ def run_init_wizard() -> None:
|
|
|
439
478
|
_print(" → https://analytics.google.com → Admin → Property Settings")
|
|
440
479
|
_print()
|
|
441
480
|
property_id = _prompt_property_id(
|
|
442
|
-
default=_existing("ga4", "property_id"),
|
|
481
|
+
default=_existing("ga4", "property_id"), # type: ignore[operator]
|
|
443
482
|
)
|
|
444
483
|
|
|
445
484
|
# Auto-discover Ads accounts
|
|
@@ -463,18 +502,20 @@ def run_init_wizard() -> None:
|
|
|
463
502
|
else:
|
|
464
503
|
_print(" No Ads accounts found. Enter manually:")
|
|
465
504
|
customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
|
|
505
|
+
except KeyboardInterrupt:
|
|
506
|
+
raise
|
|
466
507
|
except Exception as exc:
|
|
467
508
|
_print(f" Could not auto-discover Ads accounts: {exc}")
|
|
468
509
|
customer_id = _prompt_customer_id(
|
|
469
510
|
"Ads Customer ID (XXX-XXX-XXXX)",
|
|
470
|
-
default=_existing("ads", "customer_id"),
|
|
511
|
+
default=_existing("ads", "customer_id"), # type: ignore[operator]
|
|
471
512
|
)
|
|
472
513
|
else:
|
|
473
514
|
step_num += 1
|
|
474
515
|
_step_header(step_num, "Google Ads Account")
|
|
475
516
|
customer_id = _prompt_customer_id(
|
|
476
517
|
"Ads Customer ID (XXX-XXX-XXXX)",
|
|
477
|
-
default=_existing("ads", "customer_id"),
|
|
518
|
+
default=_existing("ads", "customer_id"), # type: ignore[operator]
|
|
478
519
|
)
|
|
479
520
|
|
|
480
521
|
# Safety defaults
|
|
@@ -482,7 +523,7 @@ def run_init_wizard() -> None:
|
|
|
482
523
|
_step_header(step_num, "Safety Defaults")
|
|
483
524
|
budget_str = _prompt(
|
|
484
525
|
"Max daily budget cap (safety limit)",
|
|
485
|
-
default=str(_existing("safety", "max_daily_budget", "50")),
|
|
526
|
+
default=str(_existing("safety", "max_daily_budget", "50")), # type: ignore[operator]
|
|
486
527
|
required=False,
|
|
487
528
|
)
|
|
488
529
|
try:
|
|
@@ -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(
|
|
@@ -1039,7 +1122,8 @@ def remove_entity(
|
|
|
1039
1122
|
"campaign_asset", "asset", or "customer_asset"
|
|
1040
1123
|
entity_id: The resource ID.
|
|
1041
1124
|
For keywords: "adGroupId~criterionId"
|
|
1042
|
-
For negative_keywords:
|
|
1125
|
+
For negative_keywords: "campaignId~criterionId"
|
|
1126
|
+
(use the resource_id field from get_negative_keywords)
|
|
1043
1127
|
For campaign_asset: "campaignId~assetId~fieldType"
|
|
1044
1128
|
For asset: simple asset ID
|
|
1045
1129
|
For customer_asset: "assetId~fieldType"
|
|
@@ -1211,3 +1295,40 @@ def estimate_budget(
|
|
|
1211
1295
|
forecast_days=forecast_days,
|
|
1212
1296
|
customer_id=customer_id or _config.ads.customer_id,
|
|
1213
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
|