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.
Files changed (47) hide show
  1. adloop-0.11.0/README.md → adloop-0.12.0/PKG-INFO +49 -5
  2. adloop-0.11.0/PKG-INFO → adloop-0.12.0/README.md +23 -30
  3. {adloop-0.11.0 → adloop-0.12.0}/pyproject.toml +2 -1
  4. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/forecast.py +84 -2
  5. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/auth.py +23 -0
  6. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/config.py +32 -0
  7. adloop-0.12.0/src/adloop/gsc/client.py +22 -0
  8. adloop-0.12.0/src/adloop/gsc/reports.py +137 -0
  9. adloop-0.12.0/src/adloop/pagespeed.py +169 -0
  10. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/adloop.md +28 -1
  11. adloop-0.12.0/src/adloop/safety/__init__.py +0 -0
  12. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/server.py +198 -18
  13. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/__init__.py +0 -0
  14. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/__main__.py +0 -0
  15. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/_mcp_patches.py +0 -0
  16. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/__init__.py +0 -0
  17. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/client.py +0 -0
  18. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/enums.py +0 -0
  20. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/gaql.py +0 -0
  21. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/pmax.py +0 -0
  22. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/read.py +0 -0
  23. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ads/write.py +0 -0
  24. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/cli.py +0 -0
  25. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/crossref.py +0 -0
  26. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/diagnostics.py +0 -0
  27. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/__init__.py +0 -0
  28. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/client.py +0 -0
  29. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/reports.py +0 -0
  30. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/ga4/tracking.py +0 -0
  31. {adloop-0.11.0/src/adloop/safety → adloop-0.12.0/src/adloop/gsc}/__init__.py +0 -0
  32. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/__init__.py +0 -0
  33. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/client.py +0 -0
  34. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/gtm/read.py +0 -0
  35. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/__init__.py +0 -0
  36. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  37. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  38. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/create-ad.md +0 -0
  39. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  40. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  41. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  42. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/rules_install.py +0 -0
  43. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/runtime.py +0 -0
  44. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/audit.py +0 -0
  45. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/guards.py +0 -0
  46. {adloop-0.11.0 → adloop-0.12.0}/src/adloop/safety/preview.py +0 -0
  47. {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 61 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
+ > **[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 61 tools with the same safety model. The difference is who handles the plumbing:
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 61 Tools
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 — 61 tool registrations with safety annotations
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 61 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.
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 61 tools with the same safety model. The difference is who handles the plumbing:
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 61 Tools
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 — 61 tool registrations with safety annotations
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.11.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
- ideas.append({
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