adloop 0.11.0__tar.gz → 0.12.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/README.md → adloop-0.12.0/PKG-INFO +49 -5
- adloop-0.11.0/PKG-INFO → adloop-0.12.0/README.md +23 -30
- {adloop-0.11.0 → adloop-0.12.0}/pyproject.toml +2 -1
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/forecast.py +84 -2
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/auth.py +23 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/config.py +32 -0
- adloop-0.12.0/src/adloop/gsc/client.py +22 -0
- adloop-0.12.0/src/adloop/gsc/reports.py +137 -0
- adloop-0.12.0/src/adloop/pagespeed.py +169 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/adloop.md +28 -1
- adloop-0.12.0/src/adloop/safety/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/server.py +198 -18
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/__main__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/cli.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/crossref.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.11.0/src/adloop/safety → adloop-0.12.0/src/adloop/gsc}/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/runtime.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.11.0 → adloop-0.12.0}/src/adloop/tracking.py +0 -0
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: adloop
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: The AI command center for Google Ads, GA4, and tracking code.
|
|
5
|
+
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
|
+
Author: Daniel Klose
|
|
7
|
+
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
+
Requires-Dist: google-ads>=29.0.0
|
|
11
|
+
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
|
+
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
|
+
Requires-Dist: google-api-python-client>=2.100.0
|
|
14
|
+
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
15
|
+
Requires-Dist: google-api-python-client>=2.0.0
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Homepage, https://github.com/kLOsk/adloop
|
|
21
|
+
Project-URL: Repository, https://github.com/kLOsk/adloop
|
|
22
|
+
Project-URL: Issues, https://github.com/kLOsk/adloop/issues
|
|
23
|
+
Project-URL: Changelog, https://github.com/kLOsk/adloop/releases
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
1
27
|
<div align="center">
|
|
2
28
|
|
|
3
29
|
# AdLoop
|
|
@@ -19,13 +45,13 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
19
45
|
</div>
|
|
20
46
|
|
|
21
47
|
> [!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 same
|
|
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 same 64 tools 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
49
|
|
|
24
50
|
---
|
|
25
51
|
|
|
26
52
|
## Cloud or Self-Hosted?
|
|
27
53
|
|
|
28
|
-
Both versions run the same
|
|
54
|
+
Both versions run the same 64 tools with the same safety model. The difference is who handles the plumbing:
|
|
29
55
|
|
|
30
56
|
| | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
|
|
31
57
|
|---|---|---|
|
|
@@ -63,7 +89,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
63
89
|
|
|
64
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.
|
|
65
91
|
|
|
66
|
-
## All
|
|
92
|
+
## All 64 Tools
|
|
67
93
|
|
|
68
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)
|
|
69
95
|
|
|
@@ -143,11 +169,26 @@ These tools read the live GTM container and join it with the codebase + GA4 to f
|
|
|
143
169
|
|
|
144
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.
|
|
145
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
|
+
> **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.
|
|
186
|
+
|
|
146
187
|
### Planning Tools
|
|
147
188
|
|
|
148
189
|
| Tool | What It Does |
|
|
149
190
|
|------|-------------|
|
|
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. |
|
|
191
|
+
| `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. |
|
|
151
192
|
| `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. |
|
|
152
193
|
|
|
153
194
|
### Google Ads Write Tools
|
|
@@ -225,6 +266,9 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
225
266
|
>
|
|
226
267
|
> *(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
268
|
|
|
269
|
+
> [!IMPORTANT]
|
|
270
|
+
> **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).
|
|
271
|
+
|
|
228
272
|
### Install
|
|
229
273
|
|
|
230
274
|
**From PyPI:**
|
|
@@ -394,7 +438,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
394
438
|
```
|
|
395
439
|
src/adloop/
|
|
396
440
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
397
|
-
├── server.py # FastMCP server —
|
|
441
|
+
├── server.py # FastMCP server — 64 tool registrations with safety annotations
|
|
398
442
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
399
443
|
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
|
|
400
444
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: adloop
|
|
3
|
-
Version: 0.11.0
|
|
4
|
-
Summary: The AI command center for Google Ads, GA4, and tracking code.
|
|
5
|
-
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
|
-
Author: Daniel Klose
|
|
7
|
-
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
|
-
License: MIT
|
|
9
|
-
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
-
Requires-Dist: google-ads>=29.0.0
|
|
11
|
-
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
|
-
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
|
-
Requires-Dist: google-api-python-client>=2.100.0
|
|
14
|
-
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
15
|
-
Requires-Dist: pyyaml>=6.0
|
|
16
|
-
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
17
|
-
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
18
|
-
Requires-Python: >=3.11
|
|
19
|
-
Project-URL: Homepage, https://github.com/kLOsk/adloop
|
|
20
|
-
Project-URL: Repository, https://github.com/kLOsk/adloop
|
|
21
|
-
Project-URL: Issues, https://github.com/kLOsk/adloop/issues
|
|
22
|
-
Project-URL: Changelog, https://github.com/kLOsk/adloop/releases
|
|
23
|
-
Provides-Extra: dev
|
|
24
|
-
Description-Content-Type: text/markdown
|
|
25
|
-
|
|
26
1
|
<div align="center">
|
|
27
2
|
|
|
28
3
|
# AdLoop
|
|
@@ -44,13 +19,13 @@ An MCP server that gives your AI assistant read + write access to Google Ads and
|
|
|
44
19
|
</div>
|
|
45
20
|
|
|
46
21
|
> [!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 same
|
|
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 same 64 tools 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
23
|
|
|
49
24
|
---
|
|
50
25
|
|
|
51
26
|
## Cloud or Self-Hosted?
|
|
52
27
|
|
|
53
|
-
Both versions run the same
|
|
28
|
+
Both versions run the same 64 tools with the same safety model. The difference is who handles the plumbing:
|
|
54
29
|
|
|
55
30
|
| | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
|
|
56
31
|
|---|---|---|
|
|
@@ -88,7 +63,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
|
|
|
88
63
|
|
|
89
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.
|
|
90
65
|
|
|
91
|
-
## All
|
|
66
|
+
## All 64 Tools
|
|
92
67
|
|
|
93
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)
|
|
94
69
|
|
|
@@ -168,11 +143,26 @@ These tools read the live GTM container and join it with the codebase + GA4 to f
|
|
|
168
143
|
|
|
169
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.
|
|
170
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
|
+
> **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.
|
|
160
|
+
|
|
171
161
|
### Planning Tools
|
|
172
162
|
|
|
173
163
|
| Tool | What It Does |
|
|
174
164
|
|------|-------------|
|
|
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. |
|
|
165
|
+
| `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. |
|
|
176
166
|
| `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. |
|
|
177
167
|
|
|
178
168
|
### Google Ads Write Tools
|
|
@@ -250,6 +240,9 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
250
240
|
>
|
|
251
241
|
> *(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
242
|
|
|
243
|
+
> [!IMPORTANT]
|
|
244
|
+
> **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).
|
|
245
|
+
|
|
253
246
|
### Install
|
|
254
247
|
|
|
255
248
|
**From PyPI:**
|
|
@@ -419,7 +412,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
419
412
|
```
|
|
420
413
|
src/adloop/
|
|
421
414
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
422
|
-
├── server.py # FastMCP server —
|
|
415
|
+
├── server.py # FastMCP server — 64 tool registrations with safety annotations
|
|
423
416
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
424
417
|
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
|
|
425
418
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "The AI command center for Google Ads, GA4, and tracking code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -16,6 +16,7 @@ dependencies = [
|
|
|
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
|
|
|
@@ -295,6 +295,69 @@ def _post_keyword_ideas_rest_page(
|
|
|
295
295
|
return response.json()
|
|
296
296
|
|
|
297
297
|
|
|
298
|
+
|
|
299
|
+
_MONTH_NUMBERS = {
|
|
300
|
+
"JANUARY": 1, "FEBRUARY": 2, "MARCH": 3, "APRIL": 4, "MAY": 5,
|
|
301
|
+
"JUNE": 6, "JULY": 7, "AUGUST": 8, "SEPTEMBER": 9, "OCTOBER": 10,
|
|
302
|
+
"NOVEMBER": 11, "DECEMBER": 12,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_MONTH_NAMES = {v: k.capitalize() for k, v in _MONTH_NUMBERS.items()}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_monthly_volumes(raw: list[dict]) -> list[dict]:
|
|
309
|
+
"""REST monthlySearchVolumes → compact chronological history.
|
|
310
|
+
|
|
311
|
+
Keeps the last 24 months: enough to see seasonality across two cycles
|
|
312
|
+
without flooding the response (the API returns up to ~4 years).
|
|
313
|
+
"""
|
|
314
|
+
volumes = []
|
|
315
|
+
for row in raw:
|
|
316
|
+
month = _MONTH_NUMBERS.get(str(row.get("month", "")).upper())
|
|
317
|
+
year = _maybe_int(row.get("year"))
|
|
318
|
+
if month is None or year is None:
|
|
319
|
+
continue
|
|
320
|
+
volumes.append({
|
|
321
|
+
"year": year,
|
|
322
|
+
"month": month,
|
|
323
|
+
"searches": _maybe_int(row.get("monthlySearches")) or 0,
|
|
324
|
+
})
|
|
325
|
+
volumes.sort(key=lambda v: (v["year"], v["month"]))
|
|
326
|
+
return volumes[-24:]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _seasonality_insight(idea: dict) -> str | None:
|
|
330
|
+
"""Name the peak months when demand is meaningfully seasonal (>=40%
|
|
331
|
+
above the keyword's own monthly average)."""
|
|
332
|
+
volumes = idea.get("monthly_search_volumes") or []
|
|
333
|
+
if len(volumes) < 12:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
by_month: dict[int, list[int]] = {}
|
|
337
|
+
for v in volumes:
|
|
338
|
+
by_month.setdefault(v["month"], []).append(v["searches"])
|
|
339
|
+
month_avgs = {m: sum(s) / len(s) for m, s in by_month.items()}
|
|
340
|
+
overall = sum(month_avgs.values()) / len(month_avgs)
|
|
341
|
+
if overall <= 0:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
peaks = sorted(
|
|
345
|
+
(m for m, avg in month_avgs.items() if avg >= overall * 1.4),
|
|
346
|
+
key=lambda m: month_avgs[m],
|
|
347
|
+
reverse=True,
|
|
348
|
+
)
|
|
349
|
+
if not peaks:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
names = ", ".join(_MONTH_NAMES[m] for m in peaks[:3])
|
|
353
|
+
ratio = month_avgs[peaks[0]] / overall
|
|
354
|
+
return (
|
|
355
|
+
f"'{idea['keyword']}' is seasonal: demand peaks in {names} "
|
|
356
|
+
f"(up to {ratio:.1f}x its monthly average). Plan budgets and "
|
|
357
|
+
f"campaign launches around the ramp-up, not the peak itself."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
298
361
|
def discover_keywords(
|
|
299
362
|
config: AdLoopConfig,
|
|
300
363
|
*,
|
|
@@ -304,6 +367,7 @@ def discover_keywords(
|
|
|
304
367
|
language_id: str = "1000",
|
|
305
368
|
page_size: int = 50,
|
|
306
369
|
customer_id: str = "",
|
|
370
|
+
include_monthly_volumes: bool = False,
|
|
307
371
|
) -> dict:
|
|
308
372
|
"""Discover new keyword ideas using Google Ads Keyword Planner.
|
|
309
373
|
|
|
@@ -320,6 +384,9 @@ def discover_keywords(
|
|
|
320
384
|
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
321
385
|
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
322
386
|
page_size: max number of keyword ideas to return (default 50, max 1000)
|
|
387
|
+
include_monthly_volumes: attach per-month search volume history (up to
|
|
388
|
+
~4 years, trimmed to the last 24 months, top 20 ideas only) plus a
|
|
389
|
+
seasonality insight — the "Google Trends" view for keyword demand.
|
|
323
390
|
|
|
324
391
|
Network: this tool intentionally bypasses the google-ads gRPC client for
|
|
325
392
|
KeywordPlanIdeaService and calls the v23 REST endpoint directly. The
|
|
@@ -358,7 +425,7 @@ def discover_keywords(
|
|
|
358
425
|
# ``_micros_to_currency`` preserve legitimate 0 values that
|
|
359
426
|
# falsy checks would otherwise silently drop. See the helper
|
|
360
427
|
# docstrings for the failure modes this defends against.
|
|
361
|
-
|
|
428
|
+
entry = {
|
|
362
429
|
"keyword": idea.get("text", ""),
|
|
363
430
|
"avg_monthly_searches": _maybe_int(metrics.get("avgMonthlySearches")),
|
|
364
431
|
"competition": competition,
|
|
@@ -369,7 +436,12 @@ def discover_keywords(
|
|
|
369
436
|
"high_top_of_page_bid": _micros_to_currency(
|
|
370
437
|
_maybe_int(metrics.get("highTopOfPageBidMicros"))
|
|
371
438
|
),
|
|
372
|
-
}
|
|
439
|
+
}
|
|
440
|
+
if include_monthly_volumes:
|
|
441
|
+
entry["monthly_search_volumes"] = _parse_monthly_volumes(
|
|
442
|
+
metrics.get("monthlySearchVolumes") or []
|
|
443
|
+
)
|
|
444
|
+
ideas.append(entry)
|
|
373
445
|
|
|
374
446
|
page_token = payload.get("nextPageToken") or ""
|
|
375
447
|
if not page_token:
|
|
@@ -378,6 +450,13 @@ def discover_keywords(
|
|
|
378
450
|
# Sort by avg monthly searches descending (None last)
|
|
379
451
|
ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
|
|
380
452
|
|
|
453
|
+
if include_monthly_volumes:
|
|
454
|
+
# History is context-heavy (24 rows per keyword) — keep it on the
|
|
455
|
+
# ideas that matter and drop it from the long tail.
|
|
456
|
+
for i, idea in enumerate(ideas):
|
|
457
|
+
if i >= 20:
|
|
458
|
+
idea.pop("monthly_search_volumes", None)
|
|
459
|
+
|
|
381
460
|
insights = []
|
|
382
461
|
if ideas:
|
|
383
462
|
high_competition = [i for i in ideas if i["competition"] == "HIGH"]
|
|
@@ -399,6 +478,9 @@ def discover_keywords(
|
|
|
399
478
|
f"Highest-volume idea: '{top['keyword']}' with ~{top['avg_monthly_searches']:,} "
|
|
400
479
|
f"avg monthly searches."
|
|
401
480
|
)
|
|
481
|
+
seasonality = _seasonality_insight(top)
|
|
482
|
+
if seasonality:
|
|
483
|
+
insights.append(seasonality)
|
|
402
484
|
|
|
403
485
|
return {
|
|
404
486
|
"keyword_ideas": ideas,
|
|
@@ -28,6 +28,7 @@ _ALL_SCOPES = [
|
|
|
28
28
|
"https://www.googleapis.com/auth/analytics.edit",
|
|
29
29
|
"https://www.googleapis.com/auth/adwords",
|
|
30
30
|
"https://www.googleapis.com/auth/tagmanager.readonly",
|
|
31
|
+
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
_GA4_SCOPES = [
|
|
@@ -43,6 +44,10 @@ _GTM_SCOPES = [
|
|
|
43
44
|
"https://www.googleapis.com/auth/tagmanager.readonly",
|
|
44
45
|
]
|
|
45
46
|
|
|
47
|
+
_GSC_SCOPES = [
|
|
48
|
+
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
49
|
+
]
|
|
50
|
+
|
|
46
51
|
|
|
47
52
|
class CredentialsProvider(Protocol):
|
|
48
53
|
"""Source of authenticated Google credentials for the current context.
|
|
@@ -59,6 +64,8 @@ class CredentialsProvider(Protocol):
|
|
|
59
64
|
|
|
60
65
|
def gtm_credentials(self, config: AdLoopConfig) -> Credentials: ...
|
|
61
66
|
|
|
67
|
+
def gsc_credentials(self, config: AdLoopConfig) -> Credentials: ...
|
|
68
|
+
|
|
62
69
|
|
|
63
70
|
class LocalFileCredentialsProvider:
|
|
64
71
|
"""OSS default: local credential files + interactive OAuth.
|
|
@@ -90,6 +97,10 @@ class LocalFileCredentialsProvider:
|
|
|
90
97
|
self._guard_local_only()
|
|
91
98
|
return _local_credentials(config, _GTM_SCOPES)
|
|
92
99
|
|
|
100
|
+
def gsc_credentials(self, config: AdLoopConfig) -> Credentials:
|
|
101
|
+
self._guard_local_only()
|
|
102
|
+
return _local_credentials(config, _GSC_SCOPES)
|
|
103
|
+
|
|
93
104
|
|
|
94
105
|
_active_provider: CredentialsProvider = LocalFileCredentialsProvider()
|
|
95
106
|
|
|
@@ -179,6 +190,18 @@ def get_gtm_credentials(config: AdLoopConfig) -> Credentials:
|
|
|
179
190
|
return provider.gtm_credentials(config)
|
|
180
191
|
|
|
181
192
|
|
|
193
|
+
def get_gsc_credentials(config: AdLoopConfig) -> Credentials:
|
|
194
|
+
"""Return authenticated credentials for the Search Console API."""
|
|
195
|
+
provider = _active_provider
|
|
196
|
+
if not hasattr(provider, "gsc_credentials"):
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"This deployment's credentials provider does not support "
|
|
199
|
+
"Google Search Console. GSC tools are only available where "
|
|
200
|
+
"the provider implements gsc_credentials()."
|
|
201
|
+
)
|
|
202
|
+
return provider.gsc_credentials(config)
|
|
203
|
+
|
|
204
|
+
|
|
182
205
|
def _oauth_flow(
|
|
183
206
|
config: AdLoopConfig, creds_path: Path | None = None
|
|
184
207
|
) -> Credentials:
|
|
@@ -32,6 +32,22 @@ class AdsConfig:
|
|
|
32
32
|
login_customer_id: str = ""
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
@dataclass
|
|
36
|
+
class GscConfig:
|
|
37
|
+
site_url: str = "" # e.g. "https://example.com/" or "sc-domain:example.com"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class GtmConfig:
|
|
42
|
+
account_id: str = ""
|
|
43
|
+
container_id: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PageSpeedConfig:
|
|
48
|
+
api_key: str = "" # optional — keyless PSI calls work but are rate-limited
|
|
49
|
+
|
|
50
|
+
|
|
35
51
|
@dataclass
|
|
36
52
|
class SafetyConfig:
|
|
37
53
|
max_daily_budget: float = 50.0
|
|
@@ -46,6 +62,9 @@ class AdLoopConfig:
|
|
|
46
62
|
google: GoogleConfig = field(default_factory=GoogleConfig)
|
|
47
63
|
ga4: GA4Config = field(default_factory=GA4Config)
|
|
48
64
|
ads: AdsConfig = field(default_factory=AdsConfig)
|
|
65
|
+
gsc: GscConfig = field(default_factory=GscConfig)
|
|
66
|
+
gtm: GtmConfig = field(default_factory=GtmConfig)
|
|
67
|
+
pagespeed: PageSpeedConfig = field(default_factory=PageSpeedConfig)
|
|
49
68
|
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
50
69
|
# Absolute path the config was resolved from (even if it did not exist
|
|
51
70
|
# on disk when loaded). Used by the runtime to tell callers exactly
|
|
@@ -81,6 +100,9 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
|
|
|
81
100
|
google_raw = raw.get("google", {})
|
|
82
101
|
ga4_raw = raw.get("ga4", {})
|
|
83
102
|
ads_raw = raw.get("ads", {})
|
|
103
|
+
gsc_raw = raw.get("gsc", {})
|
|
104
|
+
gtm_raw = raw.get("gtm", {})
|
|
105
|
+
pagespeed_raw = raw.get("pagespeed", {})
|
|
84
106
|
safety_raw = raw.get("safety", {})
|
|
85
107
|
|
|
86
108
|
return AdLoopConfig(
|
|
@@ -97,6 +119,16 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
|
|
|
97
119
|
customer_id=ads_raw.get("customer_id", ""),
|
|
98
120
|
login_customer_id=ads_raw.get("login_customer_id", ""),
|
|
99
121
|
),
|
|
122
|
+
gsc=GscConfig(
|
|
123
|
+
site_url=gsc_raw.get("site_url", ""),
|
|
124
|
+
),
|
|
125
|
+
gtm=GtmConfig(
|
|
126
|
+
account_id=str(gtm_raw.get("account_id", "")),
|
|
127
|
+
container_id=str(gtm_raw.get("container_id", "")),
|
|
128
|
+
),
|
|
129
|
+
pagespeed=PageSpeedConfig(
|
|
130
|
+
api_key=str(pagespeed_raw.get("api_key", "")),
|
|
131
|
+
),
|
|
100
132
|
safety=SafetyConfig(
|
|
101
133
|
max_daily_budget=safety_raw.get("max_daily_budget", 50.0),
|
|
102
134
|
max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Google Search Console API client wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from googleapiclient.discovery import Resource
|
|
9
|
+
|
|
10
|
+
from adloop.config import AdLoopConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_gsc_client(config: AdLoopConfig) -> Resource:
|
|
14
|
+
"""Return an authenticated Google Search Console API client."""
|
|
15
|
+
from googleapiclient.discovery import build
|
|
16
|
+
|
|
17
|
+
from adloop.auth import get_gsc_credentials
|
|
18
|
+
|
|
19
|
+
credentials = get_gsc_credentials(config)
|
|
20
|
+
return build(
|
|
21
|
+
"searchconsole", "v1", credentials=credentials, cache_discovery=False
|
|
22
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Google Search Console report tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Literal
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from adloop.config import AdLoopConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_gsc_sites(config: AdLoopConfig) -> dict:
|
|
12
|
+
"""List all Google Search Console properties the authenticated user can access."""
|
|
13
|
+
from adloop.gsc.client import get_gsc_client
|
|
14
|
+
|
|
15
|
+
client = get_gsc_client(config)
|
|
16
|
+
result = client.sites().list().execute()
|
|
17
|
+
|
|
18
|
+
sites = result.get("siteEntry", [])
|
|
19
|
+
return {
|
|
20
|
+
"sites": [
|
|
21
|
+
{
|
|
22
|
+
"site_url": s["siteUrl"],
|
|
23
|
+
"permission_level": s.get("permissionLevel", "unknown"),
|
|
24
|
+
}
|
|
25
|
+
for s in sites
|
|
26
|
+
],
|
|
27
|
+
"total": len(sites),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_gsc_report(
|
|
32
|
+
config: AdLoopConfig,
|
|
33
|
+
*,
|
|
34
|
+
site_url: str = "",
|
|
35
|
+
dimensions: list[str] | None = None,
|
|
36
|
+
date_range_start: str = "7daysAgo",
|
|
37
|
+
date_range_end: str = "today",
|
|
38
|
+
limit: int = 100,
|
|
39
|
+
search_type: Literal["web", "image", "video", "news", "discover", "googleNews"] = "web",
|
|
40
|
+
dimension_filter_groups: list[dict] | None = None,
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""Run a Google Search Console search analytics report.
|
|
43
|
+
|
|
44
|
+
Returns clicks, impressions, CTR, and average position for the
|
|
45
|
+
requested dimensions and date range.
|
|
46
|
+
|
|
47
|
+
dimensions: one or more of ["query", "page", "country", "device", "date"]
|
|
48
|
+
date_range_start / date_range_end: ISO dates (YYYY-MM-DD) or relative
|
|
49
|
+
values like "7daysAgo", "30daysAgo", "today"
|
|
50
|
+
search_type: "web" (default), "image", "video", "news", "discover",
|
|
51
|
+
"googleNews"
|
|
52
|
+
dimension_filter_groups: optional list of GSC DimensionFilterGroup dicts
|
|
53
|
+
to filter by query, page, country, or device. Example:
|
|
54
|
+
[{"filters": [{"dimension": "query", "operator": "contains",
|
|
55
|
+
"expression": "analytics"}]}]
|
|
56
|
+
limit: maximum number of rows to return (default 100, max 25000)
|
|
57
|
+
"""
|
|
58
|
+
from adloop.gsc.client import get_gsc_client
|
|
59
|
+
|
|
60
|
+
if not site_url:
|
|
61
|
+
site_url = config.gsc.site_url
|
|
62
|
+
|
|
63
|
+
if not site_url:
|
|
64
|
+
return {
|
|
65
|
+
"error": "site_url is required. Pass it as an argument or set "
|
|
66
|
+
"gsc.site_url in ~/.adloop/config.yaml."
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if not dimensions:
|
|
70
|
+
dimensions = ["query"]
|
|
71
|
+
|
|
72
|
+
# Resolve relative date shorthands to ISO dates
|
|
73
|
+
start_date = _resolve_date(date_range_start)
|
|
74
|
+
end_date = _resolve_date(date_range_end)
|
|
75
|
+
|
|
76
|
+
body: dict = {
|
|
77
|
+
"startDate": start_date,
|
|
78
|
+
"endDate": end_date,
|
|
79
|
+
"dimensions": dimensions,
|
|
80
|
+
"type": search_type,
|
|
81
|
+
"rowLimit": min(limit, 25_000),
|
|
82
|
+
"startRow": 0,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if dimension_filter_groups:
|
|
86
|
+
body["dimensionFilterGroups"] = dimension_filter_groups
|
|
87
|
+
|
|
88
|
+
client = get_gsc_client(config)
|
|
89
|
+
result = (
|
|
90
|
+
client.searchanalytics()
|
|
91
|
+
.query(siteUrl=site_url, body=body)
|
|
92
|
+
.execute()
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
rows = result.get("rows", [])
|
|
96
|
+
formatted = []
|
|
97
|
+
for row in rows:
|
|
98
|
+
entry: dict = {}
|
|
99
|
+
for i, dim in enumerate(dimensions):
|
|
100
|
+
entry[dim] = row["keys"][i]
|
|
101
|
+
entry["clicks"] = row.get("clicks", 0)
|
|
102
|
+
entry["impressions"] = row.get("impressions", 0)
|
|
103
|
+
entry["ctr"] = round(row.get("ctr", 0.0) * 100, 2) # as %
|
|
104
|
+
entry["position"] = round(row.get("position", 0.0), 1)
|
|
105
|
+
formatted.append(entry)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"site_url": site_url,
|
|
109
|
+
"date_range": {"start": start_date, "end": end_date},
|
|
110
|
+
"search_type": search_type,
|
|
111
|
+
"dimensions": dimensions,
|
|
112
|
+
"rows": formatted,
|
|
113
|
+
"total_rows": len(formatted),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_date(value: str) -> str:
|
|
118
|
+
"""Resolve a relative date string to ISO format (YYYY-MM-DD)."""
|
|
119
|
+
import re
|
|
120
|
+
from datetime import date, timedelta
|
|
121
|
+
|
|
122
|
+
value = value.strip()
|
|
123
|
+
if re.match(r"^\d{4}-\d{2}-\d{2}$", value):
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
today = date.today()
|
|
127
|
+
if value == "today":
|
|
128
|
+
return today.isoformat()
|
|
129
|
+
if value == "yesterday":
|
|
130
|
+
return (today - timedelta(days=1)).isoformat()
|
|
131
|
+
|
|
132
|
+
m = re.match(r"^(\d+)daysAgo$", value)
|
|
133
|
+
if m:
|
|
134
|
+
return (today - timedelta(days=int(m.group(1)))).isoformat()
|
|
135
|
+
|
|
136
|
+
# Unknown format — pass through and let the API surface the error
|
|
137
|
+
return value
|