adloop 0.11.0__tar.gz → 0.13.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.11.0 → adloop-0.13.0}/PKG-INFO +66 -11
- {adloop-0.11.0 → adloop-0.13.0}/README.md +63 -9
- {adloop-0.11.0 → adloop-0.13.0}/pyproject.toml +3 -2
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/client.py +1 -1
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/forecast.py +118 -37
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/pmax.py +1 -1
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/write.py +47 -2
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/auth.py +103 -3
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/cli.py +203 -20
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/config.py +38 -0
- adloop-0.13.0/src/adloop/ga4/write.py +100 -0
- adloop-0.13.0/src/adloop/gsc/client.py +22 -0
- adloop-0.13.0/src/adloop/gsc/reports.py +146 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/gtm/read.py +13 -1
- adloop-0.13.0/src/adloop/merchant/__init__.py +1 -0
- adloop-0.13.0/src/adloop/merchant/client.py +81 -0
- adloop-0.13.0/src/adloop/merchant/read.py +227 -0
- adloop-0.13.0/src/adloop/pagespeed.py +169 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/adloop.md +42 -2
- adloop-0.13.0/src/adloop/safety/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/safety/preview.py +5 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/server.py +363 -81
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/__main__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/crossref.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.11.0/src/adloop/safety → adloop-0.13.0/src/adloop/gsc}/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/runtime.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.11.0 → adloop-0.13.0}/src/adloop/tracking.py +0 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: The AI command center for Google Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
7
7
|
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
8
|
License: MIT
|
|
9
9
|
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
-
Requires-Dist: google-ads>=
|
|
10
|
+
Requires-Dist: google-ads>=31.1.0
|
|
11
11
|
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
12
|
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
13
|
Requires-Dist: google-api-python-client>=2.100.0
|
|
14
14
|
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
15
|
+
Requires-Dist: google-api-python-client>=2.0.0
|
|
15
16
|
Requires-Dist: pyyaml>=6.0
|
|
16
17
|
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
17
18
|
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
@@ -33,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
33
34
|
[](LICENSE)
|
|
34
35
|
[](https://www.python.org/downloads/)
|
|
35
36
|
[](https://modelcontextprotocol.io)
|
|
36
|
-
[](https://developers.google.com/google-ads/api/docs/start)
|
|
37
38
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
38
39
|
[](https://github.com/kLOsk/adloop)
|
|
39
40
|
|
|
@@ -44,13 +45,13 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
44
45
|
</div>
|
|
45
46
|
|
|
46
47
|
> [!TIP]
|
|
47
|
-
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the
|
|
48
|
+
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
48
49
|
|
|
49
50
|
---
|
|
50
51
|
|
|
51
52
|
## Cloud or Self-Hosted?
|
|
52
53
|
|
|
53
|
-
Both versions run the same
|
|
54
|
+
Both versions run the same tools with the same safety model. The difference is who handles the plumbing:
|
|
54
55
|
|
|
55
56
|
| | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
|
|
56
57
|
|---|---|---|
|
|
@@ -88,7 +89,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
88
89
|
|
|
89
90
|
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.
|
|
90
91
|
|
|
91
|
-
## All
|
|
92
|
+
## All Tools
|
|
92
93
|
|
|
93
94
|
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init` — or zero setup on [AdLoop Cloud](https://getadloop.com)
|
|
94
95
|
|
|
@@ -168,12 +169,36 @@ These tools read the live GTM container and join it with the codebase + GA4 to f
|
|
|
168
169
|
|
|
169
170
|
> **Setup for GTM tools** — Enable the **Tag Manager API v2** in your GCP project, then add your AdLoop credentials' email (the OAuth user, or the service account email if using a service account) as a **Read** user on the GTM container under Admin → User Management. Service accounts pick up access on the next call. **OAuth users upgrading from an earlier AdLoop version must re-authorize once**: the GTM scope is new, so delete `~/.adloop/token.json` and run any tool to re-consent — until then GTM tools return a permissions error.
|
|
170
171
|
|
|
172
|
+
### Search Console Tools
|
|
173
|
+
|
|
174
|
+
| Tool | What It Does |
|
|
175
|
+
|------|-------------|
|
|
176
|
+
| `list_gsc_sites` | List Search Console properties the connected account can access |
|
|
177
|
+
| `run_gsc_report` | Organic search analytics — clicks, impressions, CTR, position by query/page/country/device/date |
|
|
178
|
+
|
|
179
|
+
### Web Performance Tools
|
|
180
|
+
|
|
181
|
+
| Tool | What It Does |
|
|
182
|
+
|------|-------------|
|
|
183
|
+
| `analyze_page_speed` | PageSpeed Insights for landing pages — Lighthouse score, Core Web Vitals, real-user CrUX data, top fixes. No OAuth needed (optional API key). |
|
|
184
|
+
|
|
185
|
+
### Merchant Center Tools
|
|
186
|
+
|
|
187
|
+
| Tool | What It Does |
|
|
188
|
+
|------|-------------|
|
|
189
|
+
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
190
|
+
| `get_merchant_feed_health` | Feed health — approved/pending/disapproved counts per reporting context, top product issues with docs, account-level issues. Disapprovals silently starve Shopping/PMax. |
|
|
191
|
+
|
|
192
|
+
> **Setup for Merchant Center tools** — Enable the **Merchant API** in your GCP project (the Content API for Shopping is deprecated). The Merchant API has no read-only scope; AdLoop uses it strictly read-only. Upgrading OAuth users re-authorize once.
|
|
193
|
+
|
|
194
|
+
> **Setup for GSC tools** — Enable the **Search Console API** in your GCP project. Upgrading OAuth users must re-authorize once for the new scope (delete `~/.adloop/token.json`, run any tool). The killer combo: cross-reference organic queries with `get_keyword_performance` to find paid/organic cannibalization and untapped keyword opportunities.
|
|
195
|
+
|
|
171
196
|
### Planning Tools
|
|
172
197
|
|
|
173
198
|
| Tool | What It Does |
|
|
174
199
|
|------|-------------|
|
|
175
|
-
| `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. |
|
|
176
|
-
| `estimate_budget` | Forecast clicks,
|
|
200
|
+
| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — with optional per-month search history + seasonality insights (`include_monthly_volumes`) using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
|
|
201
|
+
| `estimate_budget` | Forecast clicks, cost, and conversions for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
|
|
177
202
|
|
|
178
203
|
### Google Ads Write Tools
|
|
179
204
|
|
|
@@ -192,6 +217,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
192
217
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
193
218
|
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
194
219
|
| `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
|
|
220
|
+
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
195
221
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
196
222
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
197
223
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
@@ -232,6 +258,7 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
232
258
|
|
|
233
259
|
- **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
|
|
234
260
|
- **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
|
|
261
|
+
- **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
|
|
235
262
|
- **Budget caps.** Configurable maximum daily budget — the server rejects anything above the cap.
|
|
236
263
|
- **Audit log.** Every operation (including dry runs) is logged to `~/.adloop/audit.log`.
|
|
237
264
|
- **New campaigns and ads are PAUSED.** Nothing goes live without manual enablement.
|
|
@@ -250,6 +277,9 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
250
277
|
>
|
|
251
278
|
> *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
|
|
252
279
|
|
|
280
|
+
> [!IMPORTANT]
|
|
281
|
+
> **Seeing `deleted_client: The OAuth client was deleted.` or `invalid_client`?** The shared Google Cloud project behind AdLoop ≤0.9's bundled credentials has been shut down, so its stored sign-ins no longer refresh. Two ways forward: **[AdLoop Cloud](https://getadloop.com)** (connect Google in two clicks, nothing to configure) or stay self-hosted with `pip install -U adloop && adloop init` to set up your own free Google Cloud project. Details in [the pinned issue (#49)](https://github.com/kLOsk/adloop/issues/49).
|
|
282
|
+
|
|
253
283
|
### Install
|
|
254
284
|
|
|
255
285
|
**From PyPI:**
|
|
@@ -277,8 +307,10 @@ The wizard walks you through:
|
|
|
277
307
|
3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
|
|
278
308
|
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
279
309
|
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
280
|
-
6. **
|
|
281
|
-
7. **
|
|
310
|
+
6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
|
|
311
|
+
7. **Safety defaults** — budget cap and dry-run preference
|
|
312
|
+
8. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
|
|
313
|
+
9. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
|
|
282
314
|
|
|
283
315
|
### Requirements
|
|
284
316
|
|
|
@@ -412,14 +444,37 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
412
444
|
| `ads` | `login_customer_id` | — | Your MCC account ID |
|
|
413
445
|
| `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
|
|
414
446
|
| `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
|
|
447
|
+
| `safety` | `two_phase_apply` | `false` | Refuse real applies until the plan had a dry-run pass |
|
|
415
448
|
| `safety` | `blocked_operations` | `[]` | Operations to block entirely |
|
|
416
449
|
|
|
450
|
+
### Toolsets — trim the context footprint
|
|
451
|
+
|
|
452
|
+
Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. AdLoop's full catalog costs roughly 18k tokens per session that way — paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
|
|
453
|
+
|
|
454
|
+
```json
|
|
455
|
+
"env": { "ADLOOP_TOOLSETS": "ads,ga4" }
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
| Toolset | Covers |
|
|
459
|
+
|---|---|
|
|
460
|
+
| `ads` | Google Ads reads, writes, and planning (Keyword Planner) |
|
|
461
|
+
| `ga4` | Google Analytics reports, realtime, key events |
|
|
462
|
+
| `tracking` | Cross-channel attribution + tracking code generation |
|
|
463
|
+
| `gtm` | Google Tag Manager audits and reads |
|
|
464
|
+
| `gsc` | Search Console reads |
|
|
465
|
+
| `web` | PageSpeed / Core Web Vitals |
|
|
466
|
+
| `merchant` | Merchant Center feed health |
|
|
467
|
+
|
|
468
|
+
`health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads,ga4` drops the session cost to ~13k tokens, and a `ga4`-only client pays ~2k — nearly 90% less. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
|
|
469
|
+
|
|
470
|
+
On [AdLoop Cloud](https://getadloop.com), the same feature is per API key: pick toolsets when creating a key in the dashboard, and that key's tools/list is trimmed server-side for whichever AI client uses it.
|
|
471
|
+
|
|
417
472
|
## Project Structure
|
|
418
473
|
|
|
419
474
|
```
|
|
420
475
|
src/adloop/
|
|
421
476
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
422
|
-
├── server.py # FastMCP server —
|
|
477
|
+
├── server.py # FastMCP server — 67 tool registrations with safety annotations
|
|
423
478
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
424
479
|
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
|
|
425
480
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](https://www.python.org/downloads/)
|
|
10
10
|
[](https://modelcontextprotocol.io)
|
|
11
|
-
[](https://developers.google.com/google-ads/api/docs/start)
|
|
12
12
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
13
13
|
[](https://github.com/kLOsk/adloop)
|
|
14
14
|
|
|
@@ -19,13 +19,13 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
19
19
|
</div>
|
|
20
20
|
|
|
21
21
|
> [!TIP]
|
|
22
|
-
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the
|
|
22
|
+
> **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
26
|
## Cloud or Self-Hosted?
|
|
27
27
|
|
|
28
|
-
Both versions run the same
|
|
28
|
+
Both versions run the same tools with the same safety model. The difference is who handles the plumbing:
|
|
29
29
|
|
|
30
30
|
| | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
|
|
31
31
|
|---|---|---|
|
|
@@ -63,7 +63,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
63
63
|
|
|
64
64
|
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.
|
|
65
65
|
|
|
66
|
-
## All
|
|
66
|
+
## All Tools
|
|
67
67
|
|
|
68
68
|
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init` — or zero setup on [AdLoop Cloud](https://getadloop.com)
|
|
69
69
|
|
|
@@ -143,12 +143,36 @@ These tools read the live GTM container and join it with the codebase + GA4 to f
|
|
|
143
143
|
|
|
144
144
|
> **Setup for GTM tools** — Enable the **Tag Manager API v2** in your GCP project, then add your AdLoop credentials' email (the OAuth user, or the service account email if using a service account) as a **Read** user on the GTM container under Admin → User Management. Service accounts pick up access on the next call. **OAuth users upgrading from an earlier AdLoop version must re-authorize once**: the GTM scope is new, so delete `~/.adloop/token.json` and run any tool to re-consent — until then GTM tools return a permissions error.
|
|
145
145
|
|
|
146
|
+
### Search Console Tools
|
|
147
|
+
|
|
148
|
+
| Tool | What It Does |
|
|
149
|
+
|------|-------------|
|
|
150
|
+
| `list_gsc_sites` | List Search Console properties the connected account can access |
|
|
151
|
+
| `run_gsc_report` | Organic search analytics — clicks, impressions, CTR, position by query/page/country/device/date |
|
|
152
|
+
|
|
153
|
+
### Web Performance Tools
|
|
154
|
+
|
|
155
|
+
| Tool | What It Does |
|
|
156
|
+
|------|-------------|
|
|
157
|
+
| `analyze_page_speed` | PageSpeed Insights for landing pages — Lighthouse score, Core Web Vitals, real-user CrUX data, top fixes. No OAuth needed (optional API key). |
|
|
158
|
+
|
|
159
|
+
### Merchant Center Tools
|
|
160
|
+
|
|
161
|
+
| Tool | What It Does |
|
|
162
|
+
|------|-------------|
|
|
163
|
+
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
164
|
+
| `get_merchant_feed_health` | Feed health — approved/pending/disapproved counts per reporting context, top product issues with docs, account-level issues. Disapprovals silently starve Shopping/PMax. |
|
|
165
|
+
|
|
166
|
+
> **Setup for Merchant Center tools** — Enable the **Merchant API** in your GCP project (the Content API for Shopping is deprecated). The Merchant API has no read-only scope; AdLoop uses it strictly read-only. Upgrading OAuth users re-authorize once.
|
|
167
|
+
|
|
168
|
+
> **Setup for GSC tools** — Enable the **Search Console API** in your GCP project. Upgrading OAuth users must re-authorize once for the new scope (delete `~/.adloop/token.json`, run any tool). The killer combo: cross-reference organic queries with `get_keyword_performance` to find paid/organic cannibalization and untapped keyword opportunities.
|
|
169
|
+
|
|
146
170
|
### Planning Tools
|
|
147
171
|
|
|
148
172
|
| Tool | What It Does |
|
|
149
173
|
|------|-------------|
|
|
150
|
-
| `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. |
|
|
151
|
-
| `estimate_budget` | Forecast clicks,
|
|
174
|
+
| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — with optional per-month search history + seasonality insights (`include_monthly_volumes`) using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
|
|
175
|
+
| `estimate_budget` | Forecast clicks, cost, and conversions for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
|
|
152
176
|
|
|
153
177
|
### Google Ads Write Tools
|
|
154
178
|
|
|
@@ -167,6 +191,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
167
191
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
168
192
|
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
169
193
|
| `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
|
|
194
|
+
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
170
195
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
171
196
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
172
197
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
@@ -207,6 +232,7 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
207
232
|
|
|
208
233
|
- **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
|
|
209
234
|
- **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
|
|
235
|
+
- **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
|
|
210
236
|
- **Budget caps.** Configurable maximum daily budget — the server rejects anything above the cap.
|
|
211
237
|
- **Audit log.** Every operation (including dry runs) is logged to `~/.adloop/audit.log`.
|
|
212
238
|
- **New campaigns and ads are PAUSED.** Nothing goes live without manual enablement.
|
|
@@ -225,6 +251,9 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
225
251
|
>
|
|
226
252
|
> *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
|
|
227
253
|
|
|
254
|
+
> [!IMPORTANT]
|
|
255
|
+
> **Seeing `deleted_client: The OAuth client was deleted.` or `invalid_client`?** The shared Google Cloud project behind AdLoop ≤0.9's bundled credentials has been shut down, so its stored sign-ins no longer refresh. Two ways forward: **[AdLoop Cloud](https://getadloop.com)** (connect Google in two clicks, nothing to configure) or stay self-hosted with `pip install -U adloop && adloop init` to set up your own free Google Cloud project. Details in [the pinned issue (#49)](https://github.com/kLOsk/adloop/issues/49).
|
|
256
|
+
|
|
228
257
|
### Install
|
|
229
258
|
|
|
230
259
|
**From PyPI:**
|
|
@@ -252,8 +281,10 @@ The wizard walks you through:
|
|
|
252
281
|
3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
|
|
253
282
|
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
254
283
|
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
255
|
-
6. **
|
|
256
|
-
7. **
|
|
284
|
+
6. **Optional services** — pin a GTM container, a Search Console property (both auto-discovered too), and a PageSpeed API key; skip any of them with Enter
|
|
285
|
+
7. **Safety defaults** — budget cap and dry-run preference
|
|
286
|
+
8. **Toolsets** — optionally expose only part of the tool catalog to your AI client (see [Toolsets](#toolsets--trim-the-context-footprint))
|
|
287
|
+
9. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code, including your toolset selection
|
|
257
288
|
|
|
258
289
|
### Requirements
|
|
259
290
|
|
|
@@ -387,14 +418,37 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
387
418
|
| `ads` | `login_customer_id` | — | Your MCC account ID |
|
|
388
419
|
| `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
|
|
389
420
|
| `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
|
|
421
|
+
| `safety` | `two_phase_apply` | `false` | Refuse real applies until the plan had a dry-run pass |
|
|
390
422
|
| `safety` | `blocked_operations` | `[]` | Operations to block entirely |
|
|
391
423
|
|
|
424
|
+
### Toolsets — trim the context footprint
|
|
425
|
+
|
|
426
|
+
Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. AdLoop's full catalog costs roughly 18k tokens per session that way — paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
"env": { "ADLOOP_TOOLSETS": "ads,ga4" }
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
| Toolset | Covers |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `ads` | Google Ads reads, writes, and planning (Keyword Planner) |
|
|
435
|
+
| `ga4` | Google Analytics reports, realtime, key events |
|
|
436
|
+
| `tracking` | Cross-channel attribution + tracking code generation |
|
|
437
|
+
| `gtm` | Google Tag Manager audits and reads |
|
|
438
|
+
| `gsc` | Search Console reads |
|
|
439
|
+
| `web` | PageSpeed / Core Web Vitals |
|
|
440
|
+
| `merchant` | Merchant Center feed health |
|
|
441
|
+
|
|
442
|
+
`health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads,ga4` drops the session cost to ~13k tokens, and a `ga4`-only client pays ~2k — nearly 90% less. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
|
|
443
|
+
|
|
444
|
+
On [AdLoop Cloud](https://getadloop.com), the same feature is per API key: pick toolsets when creating a key in the dashboard, and that key's tools/list is trimmed server-side for whichever AI client uses it.
|
|
445
|
+
|
|
392
446
|
## Project Structure
|
|
393
447
|
|
|
394
448
|
```
|
|
395
449
|
src/adloop/
|
|
396
450
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
397
|
-
├── server.py # FastMCP server —
|
|
451
|
+
├── server.py # FastMCP server — 67 tool registrations with safety annotations
|
|
398
452
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
399
453
|
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
|
|
400
454
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.0"
|
|
4
4
|
description = "The AI command center for Google Ads, GA4, and tracking code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -11,11 +11,12 @@ license = { text = "MIT" }
|
|
|
11
11
|
keywords = ["mcp", "google-ads", "google-analytics", "ga4", "cursor", "marketing"]
|
|
12
12
|
dependencies = [
|
|
13
13
|
"fastmcp>=3.0.0",
|
|
14
|
-
"google-ads>=
|
|
14
|
+
"google-ads>=31.1.0",
|
|
15
15
|
"google-analytics-data>=0.20.0",
|
|
16
16
|
"google-analytics-admin>=0.27.0",
|
|
17
17
|
"google-api-python-client>=2.100.0",
|
|
18
18
|
"google-auth-oauthlib>=1.0.0",
|
|
19
|
+
"google-api-python-client>=2.0.0",
|
|
19
20
|
"pyyaml>=6.0",
|
|
20
21
|
]
|
|
21
22
|
|
|
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|
|
16
16
|
# Pin the API version so library upgrades don't silently break field names,
|
|
17
17
|
# enum values, or mutate operation structures. Bump this deliberately when
|
|
18
18
|
# migrating to a new API version — never let it float to the library default.
|
|
19
|
-
GOOGLE_ADS_API_VERSION = "
|
|
19
|
+
GOOGLE_ADS_API_VERSION = "v24"
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
|
|
@@ -22,13 +22,16 @@ def estimate_budget(
|
|
|
22
22
|
forecast_days: int = 30,
|
|
23
23
|
customer_id: str = "",
|
|
24
24
|
) -> dict:
|
|
25
|
-
"""Forecast clicks,
|
|
25
|
+
"""Forecast clicks, cost, and conversions for a set of keywords.
|
|
26
26
|
|
|
27
27
|
Uses KeywordPlanIdeaService.GenerateKeywordForecastMetrics to estimate
|
|
28
28
|
campaign performance without creating anything. Useful for budget planning
|
|
29
29
|
before launching a new campaign.
|
|
30
30
|
|
|
31
31
|
keywords: list of {"text": str, "match_type": "EXACT|PHRASE|BROAD", "max_cpc": float (optional)}
|
|
32
|
+
Since Ads API v24 the forecast takes no per-keyword bids — the
|
|
33
|
+
highest max_cpc across the list becomes the campaign-level manual
|
|
34
|
+
CPC cap.
|
|
32
35
|
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK, 2250=France)
|
|
33
36
|
language_id: language constant (1000=English, 1001=German, 1002=French, 1003=Spanish)
|
|
34
37
|
forecast_days: number of days to forecast (default 30)
|
|
@@ -45,9 +48,6 @@ def estimate_budget(
|
|
|
45
48
|
kp_service = client.get_service("KeywordPlanIdeaService")
|
|
46
49
|
|
|
47
50
|
campaign = client.get_type("CampaignToForecast")
|
|
48
|
-
campaign.keyword_plan_network = (
|
|
49
|
-
client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
|
|
50
|
-
)
|
|
51
51
|
|
|
52
52
|
max_bid = max(
|
|
53
53
|
(int(kw.get("max_cpc", 0) * 1_000_000) for kw in keywords),
|
|
@@ -57,11 +57,9 @@ def estimate_budget(
|
|
|
57
57
|
max_bid = _DEFAULT_MAX_CPC_MICROS
|
|
58
58
|
campaign.bidding_strategy.manual_cpc_bidding_strategy.max_cpc_bid_micros = max_bid
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
geo_target_id
|
|
60
|
+
campaign.geo_target_constants.append(
|
|
61
|
+
googleads_service.geo_target_constant_path(geo_target_id)
|
|
63
62
|
)
|
|
64
|
-
campaign.geo_modifiers.append(geo_modifier)
|
|
65
63
|
|
|
66
64
|
campaign.language_constants.append(
|
|
67
65
|
googleads_service.language_constant_path(language_id)
|
|
@@ -74,15 +72,13 @@ def estimate_budget(
|
|
|
74
72
|
if not text:
|
|
75
73
|
continue
|
|
76
74
|
match_type = kw.get("match_type", "BROAD").upper()
|
|
77
|
-
cpc_micros = int(kw.get("max_cpc", 0) * 1_000_000) or _DEFAULT_MAX_CPC_MICROS
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
biddable.keyword.match_type = getattr(
|
|
76
|
+
keyword = client.get_type("KeywordInfo")
|
|
77
|
+
keyword.text = text
|
|
78
|
+
keyword.match_type = getattr(
|
|
83
79
|
client.enums.KeywordMatchTypeEnum, match_type, client.enums.KeywordMatchTypeEnum.BROAD
|
|
84
80
|
)
|
|
85
|
-
ad_group.
|
|
81
|
+
ad_group.keywords.append(keyword)
|
|
86
82
|
|
|
87
83
|
campaign.ad_groups.append(ad_group)
|
|
88
84
|
|
|
@@ -98,29 +94,29 @@ def estimate_budget(
|
|
|
98
94
|
response = kp_service.generate_keyword_forecast_metrics(request=request)
|
|
99
95
|
metrics = response.campaign_forecast_metrics
|
|
100
96
|
|
|
101
|
-
# KeywordForecastMetrics fields are ``optional
|
|
102
|
-
#
|
|
103
|
-
#
|
|
97
|
+
# KeywordForecastMetrics fields are ``optional``, so the SDK returns
|
|
98
|
+
# ``None`` for unset fields and the actual number (including 0)
|
|
99
|
+
# otherwise. Falsy checks like ``int(v) if v else None`` would
|
|
104
100
|
# silently map a real 0-click or 0-cost forecast to None and the
|
|
105
101
|
# caller couldn't tell "no data" apart from "data says zero" — same
|
|
106
102
|
# bug class as discover_keywords (issue Bug 2). Use ``is not None``
|
|
107
103
|
# throughout and the shared ``_micros_to_currency`` helper for the
|
|
108
104
|
# micros→currency conversions.
|
|
105
|
+
# v24 dropped impressions/click_through_rate from the forecast and
|
|
106
|
+
# added conversions/average_cpa_micros.
|
|
109
107
|
clicks = getattr(metrics, "clicks", None)
|
|
110
|
-
impressions = getattr(metrics, "impressions", None)
|
|
111
108
|
avg_cpc_micros = getattr(metrics, "average_cpc_micros", None)
|
|
112
109
|
cost_micros = getattr(metrics, "cost_micros", None)
|
|
113
|
-
|
|
110
|
+
conversions = getattr(metrics, "conversions", None)
|
|
111
|
+
avg_cpa_micros = getattr(metrics, "average_cpa_micros", None)
|
|
114
112
|
|
|
115
113
|
total_cost = _micros_to_currency(cost_micros)
|
|
116
114
|
avg_cpc = _micros_to_currency(avg_cpc_micros)
|
|
115
|
+
avg_cpa = _micros_to_currency(avg_cpa_micros)
|
|
117
116
|
|
|
118
117
|
days = max(forecast_days, 1)
|
|
119
118
|
daily = {
|
|
120
119
|
"clicks": round(clicks / days, 1) if clicks is not None else None,
|
|
121
|
-
"impressions": (
|
|
122
|
-
round(impressions / days, 1) if impressions is not None else None
|
|
123
|
-
),
|
|
124
120
|
"cost": round(total_cost / days, 2) if total_cost is not None else None,
|
|
125
121
|
}
|
|
126
122
|
|
|
@@ -147,15 +143,16 @@ def estimate_budget(
|
|
|
147
143
|
f"most available traffic (estimated daily cost: {daily['cost']:.2f})."
|
|
148
144
|
)
|
|
149
145
|
|
|
150
|
-
if
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
if clicks is not None and clicks == 0:
|
|
147
|
+
insights.append(
|
|
148
|
+
"Forecast shows zero clicks — keywords may be too niche, too "
|
|
149
|
+
"generic, or the max CPC too low for competitive positions."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if conversions is not None and conversions > 0 and avg_cpa is not None:
|
|
156
153
|
insights.append(
|
|
157
|
-
"
|
|
158
|
-
"
|
|
154
|
+
f"Estimated {conversions:.1f} conversions at ~{avg_cpa:.2f} avg "
|
|
155
|
+
f"CPA (based on historical conversion rates for these keywords)."
|
|
159
156
|
)
|
|
160
157
|
|
|
161
158
|
return {
|
|
@@ -164,10 +161,12 @@ def estimate_budget(
|
|
|
164
161
|
"end": end_date.isoformat(),
|
|
165
162
|
},
|
|
166
163
|
"estimated_clicks": clicks,
|
|
167
|
-
"estimated_impressions": impressions,
|
|
168
164
|
"estimated_cost": total_cost,
|
|
169
165
|
"estimated_avg_cpc": avg_cpc,
|
|
170
|
-
"
|
|
166
|
+
"estimated_conversions": (
|
|
167
|
+
round(conversions, 1) if conversions is not None else None
|
|
168
|
+
),
|
|
169
|
+
"estimated_avg_cpa": avg_cpa,
|
|
171
170
|
"daily_estimates": daily,
|
|
172
171
|
"keywords_used": len([kw for kw in keywords if kw.get("text")]),
|
|
173
172
|
"insights": insights,
|
|
@@ -227,7 +226,7 @@ def _build_keyword_ideas_rest_body(
|
|
|
227
226
|
) -> dict:
|
|
228
227
|
"""Build the JSON body for the REST generateKeywordIdeas endpoint.
|
|
229
228
|
|
|
230
|
-
Schema follows google-ads REST
|
|
229
|
+
Schema follows google-ads REST v24 (camelCase). Exactly one of
|
|
231
230
|
``keywordSeed`` / ``urlSeed`` / ``keywordAndUrlSeed`` is set based on
|
|
232
231
|
which inputs were provided.
|
|
233
232
|
"""
|
|
@@ -255,7 +254,7 @@ def _post_keyword_ideas_rest_page(
|
|
|
255
254
|
|
|
256
255
|
Issue #37: ``KeywordPlanIdeaService.GenerateKeywordIdeas`` over gRPC sits
|
|
257
256
|
in a tight quota bucket that exhausts after a small number of sequential
|
|
258
|
-
calls and returns ``RESOURCE_EXHAUSTED`` regardless of QPS. The REST
|
|
257
|
+
calls and returns ``RESOURCE_EXHAUSTED`` regardless of QPS. The REST
|
|
259
258
|
endpoint for the same method lives in a separate, much larger quota
|
|
260
259
|
bucket, so this swap eliminates the 429s that made ``discover_keywords``
|
|
261
260
|
unusable for any multi-geo or repeat-call workflow. Filed against
|
|
@@ -295,6 +294,69 @@ def _post_keyword_ideas_rest_page(
|
|
|
295
294
|
return response.json()
|
|
296
295
|
|
|
297
296
|
|
|
297
|
+
|
|
298
|
+
_MONTH_NUMBERS = {
|
|
299
|
+
"JANUARY": 1, "FEBRUARY": 2, "MARCH": 3, "APRIL": 4, "MAY": 5,
|
|
300
|
+
"JUNE": 6, "JULY": 7, "AUGUST": 8, "SEPTEMBER": 9, "OCTOBER": 10,
|
|
301
|
+
"NOVEMBER": 11, "DECEMBER": 12,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_MONTH_NAMES = {v: k.capitalize() for k, v in _MONTH_NUMBERS.items()}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _parse_monthly_volumes(raw: list[dict]) -> list[dict]:
|
|
308
|
+
"""REST monthlySearchVolumes → compact chronological history.
|
|
309
|
+
|
|
310
|
+
Keeps the last 24 months: enough to see seasonality across two cycles
|
|
311
|
+
without flooding the response (the API returns up to ~4 years).
|
|
312
|
+
"""
|
|
313
|
+
volumes = []
|
|
314
|
+
for row in raw:
|
|
315
|
+
month = _MONTH_NUMBERS.get(str(row.get("month", "")).upper())
|
|
316
|
+
year = _maybe_int(row.get("year"))
|
|
317
|
+
if month is None or year is None:
|
|
318
|
+
continue
|
|
319
|
+
volumes.append({
|
|
320
|
+
"year": year,
|
|
321
|
+
"month": month,
|
|
322
|
+
"searches": _maybe_int(row.get("monthlySearches")) or 0,
|
|
323
|
+
})
|
|
324
|
+
volumes.sort(key=lambda v: (v["year"], v["month"]))
|
|
325
|
+
return volumes[-24:]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _seasonality_insight(idea: dict) -> str | None:
|
|
329
|
+
"""Name the peak months when demand is meaningfully seasonal (>=40%
|
|
330
|
+
above the keyword's own monthly average)."""
|
|
331
|
+
volumes = idea.get("monthly_search_volumes") or []
|
|
332
|
+
if len(volumes) < 12:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
by_month: dict[int, list[int]] = {}
|
|
336
|
+
for v in volumes:
|
|
337
|
+
by_month.setdefault(v["month"], []).append(v["searches"])
|
|
338
|
+
month_avgs = {m: sum(s) / len(s) for m, s in by_month.items()}
|
|
339
|
+
overall = sum(month_avgs.values()) / len(month_avgs)
|
|
340
|
+
if overall <= 0:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
peaks = sorted(
|
|
344
|
+
(m for m, avg in month_avgs.items() if avg >= overall * 1.4),
|
|
345
|
+
key=lambda m: month_avgs[m],
|
|
346
|
+
reverse=True,
|
|
347
|
+
)
|
|
348
|
+
if not peaks:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
names = ", ".join(_MONTH_NAMES[m] for m in peaks[:3])
|
|
352
|
+
ratio = month_avgs[peaks[0]] / overall
|
|
353
|
+
return (
|
|
354
|
+
f"'{idea['keyword']}' is seasonal: demand peaks in {names} "
|
|
355
|
+
f"(up to {ratio:.1f}x its monthly average). Plan budgets and "
|
|
356
|
+
f"campaign launches around the ramp-up, not the peak itself."
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
298
360
|
def discover_keywords(
|
|
299
361
|
config: AdLoopConfig,
|
|
300
362
|
*,
|
|
@@ -304,6 +366,7 @@ def discover_keywords(
|
|
|
304
366
|
language_id: str = "1000",
|
|
305
367
|
page_size: int = 50,
|
|
306
368
|
customer_id: str = "",
|
|
369
|
+
include_monthly_volumes: bool = False,
|
|
307
370
|
) -> dict:
|
|
308
371
|
"""Discover new keyword ideas using Google Ads Keyword Planner.
|
|
309
372
|
|
|
@@ -320,9 +383,12 @@ def discover_keywords(
|
|
|
320
383
|
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
321
384
|
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
322
385
|
page_size: max number of keyword ideas to return (default 50, max 1000)
|
|
386
|
+
include_monthly_volumes: attach per-month search volume history (up to
|
|
387
|
+
~4 years, trimmed to the last 24 months, top 20 ideas only) plus a
|
|
388
|
+
seasonality insight — the "Google Trends" view for keyword demand.
|
|
323
389
|
|
|
324
390
|
Network: this tool intentionally bypasses the google-ads gRPC client for
|
|
325
|
-
KeywordPlanIdeaService and calls the
|
|
391
|
+
KeywordPlanIdeaService and calls the versioned REST endpoint directly. The
|
|
326
392
|
gRPC quota bucket for this single method exhausts almost immediately
|
|
327
393
|
under sequential single-geo calls (issue #37); REST sits in a separate,
|
|
328
394
|
much larger bucket and works without issue. All other Ads tools still
|
|
@@ -358,7 +424,7 @@ def discover_keywords(
|
|
|
358
424
|
# ``_micros_to_currency`` preserve legitimate 0 values that
|
|
359
425
|
# falsy checks would otherwise silently drop. See the helper
|
|
360
426
|
# docstrings for the failure modes this defends against.
|
|
361
|
-
|
|
427
|
+
entry = {
|
|
362
428
|
"keyword": idea.get("text", ""),
|
|
363
429
|
"avg_monthly_searches": _maybe_int(metrics.get("avgMonthlySearches")),
|
|
364
430
|
"competition": competition,
|
|
@@ -369,7 +435,12 @@ def discover_keywords(
|
|
|
369
435
|
"high_top_of_page_bid": _micros_to_currency(
|
|
370
436
|
_maybe_int(metrics.get("highTopOfPageBidMicros"))
|
|
371
437
|
),
|
|
372
|
-
}
|
|
438
|
+
}
|
|
439
|
+
if include_monthly_volumes:
|
|
440
|
+
entry["monthly_search_volumes"] = _parse_monthly_volumes(
|
|
441
|
+
metrics.get("monthlySearchVolumes") or []
|
|
442
|
+
)
|
|
443
|
+
ideas.append(entry)
|
|
373
444
|
|
|
374
445
|
page_token = payload.get("nextPageToken") or ""
|
|
375
446
|
if not page_token:
|
|
@@ -378,6 +449,13 @@ def discover_keywords(
|
|
|
378
449
|
# Sort by avg monthly searches descending (None last)
|
|
379
450
|
ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
|
|
380
451
|
|
|
452
|
+
if include_monthly_volumes:
|
|
453
|
+
# History is context-heavy (24 rows per keyword) — keep it on the
|
|
454
|
+
# ideas that matter and drop it from the long tail.
|
|
455
|
+
for i, idea in enumerate(ideas):
|
|
456
|
+
if i >= 20:
|
|
457
|
+
idea.pop("monthly_search_volumes", None)
|
|
458
|
+
|
|
381
459
|
insights = []
|
|
382
460
|
if ideas:
|
|
383
461
|
high_competition = [i for i in ideas if i["competition"] == "HIGH"]
|
|
@@ -399,6 +477,9 @@ def discover_keywords(
|
|
|
399
477
|
f"Highest-volume idea: '{top['keyword']}' with ~{top['avg_monthly_searches']:,} "
|
|
400
478
|
f"avg monthly searches."
|
|
401
479
|
)
|
|
480
|
+
seasonality = _seasonality_insight(top)
|
|
481
|
+
if seasonality:
|
|
482
|
+
insights.append(seasonality)
|
|
402
483
|
|
|
403
484
|
return {
|
|
404
485
|
"keyword_ideas": ideas,
|