adloop 0.2.0__tar.gz → 0.4.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 (28) hide show
  1. {adloop-0.2.0 → adloop-0.4.0}/PKG-INFO +36 -47
  2. {adloop-0.2.0 → adloop-0.4.0}/README.md +34 -45
  3. {adloop-0.2.0 → adloop-0.4.0}/pyproject.toml +2 -2
  4. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/__init__.py +1 -1
  5. adloop-0.4.0/src/adloop/ads/pmax.py +257 -0
  6. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/read.py +205 -0
  7. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/write.py +41 -11
  8. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/auth.py +73 -9
  9. adloop-0.4.0/src/adloop/bundled_credentials.json +12 -0
  10. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/cli.py +244 -74
  11. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/config.py +2 -2
  12. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/server.py +153 -0
  13. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/__main__.py +0 -0
  14. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/__init__.py +0 -0
  15. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/client.py +0 -0
  16. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/currency.py +0 -0
  17. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/forecast.py +0 -0
  18. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/gaql.py +0 -0
  19. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/crossref.py +0 -0
  20. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/__init__.py +0 -0
  21. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/client.py +0 -0
  22. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/reports.py +0 -0
  23. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/tracking.py +0 -0
  24. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/__init__.py +0 -0
  25. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/audit.py +0 -0
  26. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/guards.py +0 -0
  27. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/preview.py +0 -0
  28. {adloop-0.2.0 → adloop-0.4.0}/src/adloop/tracking.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.2.0
4
- Summary: MCP server connecting Google Ads + GA4 + codebase into one AI-driven feedback loop
3
+ Version: 0.4.0
4
+ Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
7
7
  Author-email: Daniel Klose <info@daniel-klose.com>
@@ -66,7 +66,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
66
66
 
67
67
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
68
68
 
69
- ## All 33 Tools
69
+ ## All 38 Tools
70
70
 
71
71
  > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
72
72
 
@@ -95,6 +95,11 @@ The best features come from real workflows. If you're using AdLoop and find your
95
95
  | `get_keyword_performance` | Keywords — quality scores, competitive metrics |
96
96
  | `get_search_terms` | What users actually searched before clicking |
97
97
  | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns |
98
+ | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
99
+ | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
100
+ | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
101
+ | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
102
+ | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
98
103
  | `run_gaql` | Arbitrary GAQL queries for anything else |
99
104
 
100
105
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -204,28 +209,32 @@ uv sync
204
209
  uv run adloop init
205
210
  ```
206
211
 
207
- The `adloop init` wizard walks you through everything:
212
+ The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
208
213
 
209
- 1. **Google Cloud checklist** with clickable links to each setup page
210
- 2. **Credentials** — prompts for your OAuth JSON path, validates the file exists
211
- 3. **GA4 Property ID** — validates numeric format
212
- 4. **Developer Token** — from your Google Ads MCC API Center
213
- 5. **Customer IDs** — auto-formats `1234567890` `123-456-7890`
214
- 6. **Safety defaults** — budget cap and dry-run preference
215
- 7. **OAuth authorization** — optionally opens your browser to complete auth immediately
216
- 8. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
214
+ The wizard:
215
+
216
+ 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
217
+ 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
218
+ 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
219
+ 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
220
+ 5. **Safety defaults** — budget cap and dry-run preference
221
+ 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
217
222
 
218
223
  ### Requirements
219
224
 
220
225
  - Python 3.11+
221
- - [uv](https://docs.astral.sh/uv/) for package management
222
- - A Google Cloud project (free tier works)
223
226
  - A Google Ads account with an MCC (Manager Account)
224
227
 
225
- ### Manual Setup (If Not Using the Wizard)
228
+ ### Headless Servers
229
+
230
+ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
231
+
232
+ ### Advanced Setup (Custom Google Cloud Project)
226
233
 
227
234
  <details>
228
- <summary>Click to expand manual setup steps</summary>
235
+ <summary>Click to expand only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
236
+
237
+ When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
229
238
 
230
239
  #### Step 1 — Google Cloud Project
231
240
 
@@ -242,8 +251,6 @@ The `adloop init` wizard walks you through everything:
242
251
  3. Select **Desktop app** as the application type, give it any name
243
252
  4. Download the JSON file and save it as `~/.adloop/credentials.json`
244
253
 
245
- On first run, AdLoop opens a browser window where you sign in with your Google account and grant access. The resulting token is saved to `~/.adloop/token.json` and refreshed automatically.
246
-
247
254
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
248
255
 
249
256
  #### Step 3 — Google Ads Developer Token
@@ -256,28 +263,7 @@ Access levels:
256
263
  - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
257
264
  - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
258
265
 
259
- #### Step 4 — Find Your IDs
260
-
261
- | ID | Where to Find It |
262
- |----|-------------------|
263
- | **GA4 Property ID** | GA4 → Admin → Property Settings (numeric, e.g. `123456789`) |
264
- | **Google Ads Customer ID** | Google Ads UI → top bar (e.g. `123-456-7890`) |
265
- | **MCC Account ID** | MCC UI → top bar (e.g. `123-456-7890`) |
266
-
267
- #### Step 5 — Install and Configure
268
-
269
- ```bash
270
- git clone https://github.com/kLOsk/adloop.git
271
- cd adloop
272
- uv sync
273
-
274
- mkdir -p ~/.adloop
275
- cp config.yaml.example ~/.adloop/config.yaml
276
- ```
277
-
278
- Edit `~/.adloop/config.yaml` and fill in the values from the previous steps. See [`config.yaml.example`](config.yaml.example) for a fully documented template.
279
-
280
- #### Step 6 — Connect to Your Editor
266
+ #### Step 4 — Connect to Your Editor
281
267
 
282
268
  **Cursor** — Add to your project's `.cursor/mcp.json`:
283
269
 
@@ -336,12 +322,12 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
336
322
 
337
323
  | Section | Key | Default | Description |
338
324
  |---------|-----|---------|-------------|
339
- | `google` | `project_id` | | Your Google Cloud project ID |
340
- | `google` | `credentials_path` | `~/.adloop/credentials.json` | Path to OAuth client JSON or service account key |
325
+ | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
326
+ | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
341
327
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
342
- | `ga4` | `property_id` | — | Your GA4 property ID (found in GA4 Admin → Property Settings) |
328
+ | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
343
329
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
344
- | `ads` | `customer_id` | — | Default Google Ads customer ID |
330
+ | `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
345
331
  | `ads` | `login_customer_id` | — | Your MCC account ID |
346
332
  | `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
347
333
  | `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
@@ -352,9 +338,9 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
352
338
  ```
353
339
  src/adloop/
354
340
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
355
- ├── server.py # FastMCP server — 26 tool registrations with safety annotations
341
+ ├── server.py # FastMCP server — 38 tool registrations with safety annotations
356
342
  ├── config.py # Config loader (~/.adloop/config.yaml)
357
- ├── auth.py # OAuth 2.0 Desktop flow + service account support + token refresh handling
343
+ ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
358
344
  ├── cli.py # Interactive 'adloop init' setup wizard
359
345
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
360
346
  ├── tracking.py # Tracking validation + code generation tools
@@ -365,7 +351,8 @@ src/adloop/
365
351
  ├── ads/
366
352
  │ ├── client.py # Google Ads API client (version-pinned)
367
353
  │ ├── gaql.py # GAQL query execution with human-readable error parsing
368
- │ ├── read.py # Campaign, ad, keyword, search term, negative keyword reads
354
+ │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
355
+ │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
369
356
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
370
357
  │ └── forecast.py # Budget estimation via Keyword Planner API
371
358
  └── safety/
@@ -386,6 +373,8 @@ What's been shipped and what's next:
386
373
  - ~~Setup wizard (`adloop init`)~~ ✓
387
374
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
388
375
  - ~~PyPI package~~ ✓ — `pip install adloop`
376
+ - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
377
+ - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
389
378
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
390
379
  - **Video walkthrough**
391
380
 
@@ -42,7 +42,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
42
42
 
43
43
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
44
44
 
45
- ## All 33 Tools
45
+ ## All 38 Tools
46
46
 
47
47
  > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
48
48
 
@@ -71,6 +71,11 @@ The best features come from real workflows. If you're using AdLoop and find your
71
71
  | `get_keyword_performance` | Keywords — quality scores, competitive metrics |
72
72
  | `get_search_terms` | What users actually searched before clicking |
73
73
  | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns |
74
+ | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
75
+ | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
76
+ | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
77
+ | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
78
+ | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
74
79
  | `run_gaql` | Arbitrary GAQL queries for anything else |
75
80
 
76
81
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -180,28 +185,32 @@ uv sync
180
185
  uv run adloop init
181
186
  ```
182
187
 
183
- The `adloop init` wizard walks you through everything:
188
+ The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
184
189
 
185
- 1. **Google Cloud checklist** with clickable links to each setup page
186
- 2. **Credentials** — prompts for your OAuth JSON path, validates the file exists
187
- 3. **GA4 Property ID** — validates numeric format
188
- 4. **Developer Token** — from your Google Ads MCC API Center
189
- 5. **Customer IDs** — auto-formats `1234567890` `123-456-7890`
190
- 6. **Safety defaults** — budget cap and dry-run preference
191
- 7. **OAuth authorization** — optionally opens your browser to complete auth immediately
192
- 8. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
190
+ The wizard:
191
+
192
+ 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
193
+ 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
194
+ 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
195
+ 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
196
+ 5. **Safety defaults** — budget cap and dry-run preference
197
+ 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
193
198
 
194
199
  ### Requirements
195
200
 
196
201
  - Python 3.11+
197
- - [uv](https://docs.astral.sh/uv/) for package management
198
- - A Google Cloud project (free tier works)
199
202
  - A Google Ads account with an MCC (Manager Account)
200
203
 
201
- ### Manual Setup (If Not Using the Wizard)
204
+ ### Headless Servers
205
+
206
+ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
207
+
208
+ ### Advanced Setup (Custom Google Cloud Project)
202
209
 
203
210
  <details>
204
- <summary>Click to expand manual setup steps</summary>
211
+ <summary>Click to expand only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
212
+
213
+ When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
205
214
 
206
215
  #### Step 1 — Google Cloud Project
207
216
 
@@ -218,8 +227,6 @@ The `adloop init` wizard walks you through everything:
218
227
  3. Select **Desktop app** as the application type, give it any name
219
228
  4. Download the JSON file and save it as `~/.adloop/credentials.json`
220
229
 
221
- On first run, AdLoop opens a browser window where you sign in with your Google account and grant access. The resulting token is saved to `~/.adloop/token.json` and refreshed automatically.
222
-
223
230
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
224
231
 
225
232
  #### Step 3 — Google Ads Developer Token
@@ -232,28 +239,7 @@ Access levels:
232
239
  - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
233
240
  - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
234
241
 
235
- #### Step 4 — Find Your IDs
236
-
237
- | ID | Where to Find It |
238
- |----|-------------------|
239
- | **GA4 Property ID** | GA4 → Admin → Property Settings (numeric, e.g. `123456789`) |
240
- | **Google Ads Customer ID** | Google Ads UI → top bar (e.g. `123-456-7890`) |
241
- | **MCC Account ID** | MCC UI → top bar (e.g. `123-456-7890`) |
242
-
243
- #### Step 5 — Install and Configure
244
-
245
- ```bash
246
- git clone https://github.com/kLOsk/adloop.git
247
- cd adloop
248
- uv sync
249
-
250
- mkdir -p ~/.adloop
251
- cp config.yaml.example ~/.adloop/config.yaml
252
- ```
253
-
254
- Edit `~/.adloop/config.yaml` and fill in the values from the previous steps. See [`config.yaml.example`](config.yaml.example) for a fully documented template.
255
-
256
- #### Step 6 — Connect to Your Editor
242
+ #### Step 4 — Connect to Your Editor
257
243
 
258
244
  **Cursor** — Add to your project's `.cursor/mcp.json`:
259
245
 
@@ -312,12 +298,12 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
312
298
 
313
299
  | Section | Key | Default | Description |
314
300
  |---------|-----|---------|-------------|
315
- | `google` | `project_id` | | Your Google Cloud project ID |
316
- | `google` | `credentials_path` | `~/.adloop/credentials.json` | Path to OAuth client JSON or service account key |
301
+ | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
302
+ | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
317
303
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
318
- | `ga4` | `property_id` | — | Your GA4 property ID (found in GA4 Admin → Property Settings) |
304
+ | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
319
305
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
320
- | `ads` | `customer_id` | — | Default Google Ads customer ID |
306
+ | `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
321
307
  | `ads` | `login_customer_id` | — | Your MCC account ID |
322
308
  | `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
323
309
  | `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
@@ -328,9 +314,9 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
328
314
  ```
329
315
  src/adloop/
330
316
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
331
- ├── server.py # FastMCP server — 26 tool registrations with safety annotations
317
+ ├── server.py # FastMCP server — 38 tool registrations with safety annotations
332
318
  ├── config.py # Config loader (~/.adloop/config.yaml)
333
- ├── auth.py # OAuth 2.0 Desktop flow + service account support + token refresh handling
319
+ ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
334
320
  ├── cli.py # Interactive 'adloop init' setup wizard
335
321
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
336
322
  ├── tracking.py # Tracking validation + code generation tools
@@ -341,7 +327,8 @@ src/adloop/
341
327
  ├── ads/
342
328
  │ ├── client.py # Google Ads API client (version-pinned)
343
329
  │ ├── gaql.py # GAQL query execution with human-readable error parsing
344
- │ ├── read.py # Campaign, ad, keyword, search term, negative keyword reads
330
+ │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
331
+ │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
345
332
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
346
333
  │ └── forecast.py # Budget estimation via Keyword Planner API
347
334
  └── safety/
@@ -362,6 +349,8 @@ What's been shipped and what's next:
362
349
  - ~~Setup wizard (`adloop init`)~~ ✓
363
350
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
364
351
  - ~~PyPI package~~ ✓ — `pip install adloop`
352
+ - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
353
+ - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
365
354
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
366
355
  - **Video walkthrough**
367
356
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.2.0"
4
- description = "MCP server connecting Google Ads + GA4 + codebase into one AI-driven feedback loop"
3
+ version = "0.4.0"
4
+ description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Daniel Klose", email = "info@daniel-klose.com" }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -0,0 +1,257 @@
1
+ """Performance Max read tools — campaign, asset group, and asset performance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from adloop.ads.currency import get_currency_code
8
+ from adloop.ads.read import _date_clause, _enrich_cost_fields
9
+
10
+ if TYPE_CHECKING:
11
+ from adloop.config import AdLoopConfig
12
+
13
+
14
+ def get_pmax_performance(
15
+ config: AdLoopConfig,
16
+ *,
17
+ customer_id: str = "",
18
+ date_range_start: str = "",
19
+ date_range_end: str = "",
20
+ ) -> dict:
21
+ """Get Performance Max campaign metrics with network breakdown and asset group ad strength."""
22
+ from adloop.ads.gaql import execute_query
23
+
24
+ date_clause = _date_clause(date_range_start, date_range_end)
25
+
26
+ campaign_query = f"""
27
+ SELECT campaign.id, campaign.name, campaign.status,
28
+ campaign.bidding_strategy_type,
29
+ metrics.impressions, metrics.clicks, metrics.cost_micros,
30
+ metrics.conversions, metrics.conversions_value,
31
+ metrics.ctr, metrics.average_cpc,
32
+ segments.ad_network_type
33
+ FROM campaign
34
+ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
35
+ AND campaign.status != 'REMOVED'
36
+ {date_clause}
37
+ ORDER BY metrics.cost_micros DESC
38
+ """
39
+
40
+ campaigns = execute_query(config, customer_id, campaign_query)
41
+ currency_code = get_currency_code(config, customer_id)
42
+ _enrich_cost_fields(campaigns, currency_code)
43
+
44
+ asset_group_query = f"""
45
+ SELECT asset_group.id, asset_group.name, asset_group.status,
46
+ asset_group.ad_strength, asset_group.campaign,
47
+ metrics.impressions, metrics.clicks, metrics.cost_micros,
48
+ metrics.conversions, metrics.conversions_value
49
+ FROM asset_group
50
+ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
51
+ {date_clause}
52
+ ORDER BY metrics.cost_micros DESC
53
+ """
54
+
55
+ asset_groups = execute_query(config, customer_id, asset_group_query)
56
+ _enrich_cost_fields(asset_groups, currency_code)
57
+
58
+ insights: list[str] = []
59
+
60
+ poor_strength = [
61
+ ag for ag in asset_groups
62
+ if ag.get("asset_group.ad_strength") in ("POOR", "AVERAGE")
63
+ ]
64
+ if poor_strength:
65
+ names = ", ".join(
66
+ ag.get("asset_group.name", "?") for ag in poor_strength[:5]
67
+ )
68
+ insights.append(
69
+ f"{len(poor_strength)} asset group(s) have POOR or AVERAGE ad strength: "
70
+ f"{names}. Add more diverse assets (headlines, descriptions, images) to improve."
71
+ )
72
+
73
+ zero_conv_groups = [
74
+ ag for ag in asset_groups
75
+ if (ag.get("metrics.clicks", 0) or 0) > 10
76
+ and (ag.get("metrics.conversions", 0) or 0) == 0
77
+ ]
78
+ if zero_conv_groups:
79
+ names = ", ".join(
80
+ ag.get("asset_group.name", "?") for ag in zero_conv_groups[:5]
81
+ )
82
+ insights.append(
83
+ f"{len(zero_conv_groups)} asset group(s) have clicks but zero conversions: {names}."
84
+ )
85
+
86
+ network_counts: dict[str, int] = {}
87
+ for c in campaigns:
88
+ net = c.get("segments.ad_network_type", "UNKNOWN")
89
+ network_counts[net] = network_counts.get(net, 0) + (
90
+ c.get("metrics.impressions", 0) or 0
91
+ )
92
+ if network_counts:
93
+ total_impr = sum(network_counts.values()) or 1
94
+ dist = {
95
+ k: f"{round(v / total_impr * 100, 1)}%"
96
+ for k, v in sorted(network_counts.items(), key=lambda x: -x[1])
97
+ }
98
+ insights.append(f"PMax network distribution by impressions: {dist}")
99
+
100
+ if not campaigns:
101
+ insights.append("No Performance Max campaigns found in this account.")
102
+
103
+ return {
104
+ "campaigns": campaigns,
105
+ "asset_groups": asset_groups,
106
+ "total_campaigns": len(campaigns),
107
+ "total_asset_groups": len(asset_groups),
108
+ "insights": insights,
109
+ }
110
+
111
+
112
+ def get_asset_performance(
113
+ config: AdLoopConfig,
114
+ *,
115
+ customer_id: str = "",
116
+ campaign_id: str = "",
117
+ ) -> dict:
118
+ """Get per-asset details for Performance Max asset groups.
119
+
120
+ Returns each asset's field type, status, primary serving status, and
121
+ content. ``primary_status`` shows whether the asset is eligible to serve
122
+ (ELIGIBLE, NOT_ELIGIBLE, PAUSED, PENDING).
123
+
124
+ Note: the Google Ads API v23 does not expose per-asset performance labels
125
+ (BEST/GOOD/LOW) for PMax assets via GAQL. Use ``get_detailed_asset_performance``
126
+ to see which asset *combinations* Google selects most — that's the closest
127
+ proxy for individual asset quality.
128
+ """
129
+ from adloop.ads.gaql import execute_query
130
+
131
+ campaign_filter = ""
132
+ if campaign_id:
133
+ campaign_filter = f"AND campaign.id = {campaign_id}"
134
+
135
+ query = f"""
136
+ SELECT asset_group_asset.asset, asset_group_asset.field_type,
137
+ asset_group_asset.status, asset_group_asset.primary_status,
138
+ asset_group.id, asset_group.name,
139
+ campaign.id, campaign.name,
140
+ asset.name, asset.type,
141
+ asset.text_asset.text,
142
+ asset.image_asset.full_size.url
143
+ FROM asset_group_asset
144
+ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
145
+ {campaign_filter}
146
+ """
147
+
148
+ rows = execute_query(config, customer_id, query)
149
+
150
+ status_counts: dict[str, int] = {}
151
+ field_type_counts: dict[str, int] = {}
152
+ for row in rows:
153
+ status = row.get("asset_group_asset.primary_status", "UNSPECIFIED")
154
+ status_counts[status] = status_counts.get(status, 0) + 1
155
+ ftype = row.get("asset_group_asset.field_type", "UNSPECIFIED")
156
+ field_type_counts[ftype] = field_type_counts.get(ftype, 0) + 1
157
+
158
+ insights: list[str] = []
159
+ if not rows:
160
+ insights.append(
161
+ "No PMax assets found. This account has no Performance Max campaigns, "
162
+ "or the specified campaign_id does not match a PMax campaign."
163
+ )
164
+ else:
165
+ not_eligible = [
166
+ r for r in rows
167
+ if r.get("asset_group_asset.primary_status") == "NOT_ELIGIBLE"
168
+ ]
169
+ if not_eligible:
170
+ insights.append(
171
+ f"{len(not_eligible)} asset(s) are NOT_ELIGIBLE to serve — "
172
+ f"check policy status or asset quality."
173
+ )
174
+
175
+ return {
176
+ "assets": rows,
177
+ "total_assets": len(rows),
178
+ "by_status": status_counts,
179
+ "by_field_type": field_type_counts,
180
+ "insights": insights,
181
+ }
182
+
183
+
184
+ def get_detailed_asset_performance(
185
+ config: AdLoopConfig,
186
+ *,
187
+ customer_id: str = "",
188
+ campaign_id: str = "",
189
+ ) -> dict:
190
+ """Get top-performing asset combinations for Performance Max campaigns.
191
+
192
+ Uses the ``asset_group_top_combination_view`` resource directly (not
193
+ ``execute_query``) because the nested proto structure requires custom
194
+ extraction.
195
+ """
196
+ from adloop.ads.client import get_ads_client, normalize_customer_id
197
+
198
+ client = get_ads_client(config)
199
+ service = client.get_service("GoogleAdsService")
200
+ cid = normalize_customer_id(customer_id)
201
+
202
+ campaign_filter = ""
203
+ if campaign_id:
204
+ campaign_filter = f"AND campaign.id = {campaign_id}"
205
+
206
+ query = f"""
207
+ SELECT asset_group_top_combination_view.asset_group_top_combinations,
208
+ campaign.id, campaign.name,
209
+ asset_group.id, asset_group.name
210
+ FROM asset_group_top_combination_view
211
+ WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX'
212
+ {campaign_filter}
213
+ """
214
+
215
+ combinations: list[dict] = []
216
+ for row in service.search(customer_id=cid, query=query):
217
+ campaign_name = row.campaign.name
218
+ campaign_id_val = row.campaign.id
219
+ ag_name = row.asset_group.name
220
+ ag_id = row.asset_group.id
221
+
222
+ top_combos = row.asset_group_top_combination_view.asset_group_top_combinations
223
+ for combo in top_combos:
224
+ assets_in_combo: list[dict] = []
225
+ for served_info in getattr(combo, "asset_combination_serving_infos", []):
226
+ for asset_usage in getattr(served_info, "served_assets", []):
227
+ field_type = getattr(asset_usage, "served_asset_field_type", None)
228
+ assets_in_combo.append({
229
+ "asset": getattr(asset_usage, "asset", ""),
230
+ "field_type": (
231
+ field_type.name
232
+ if hasattr(field_type, "name")
233
+ else str(field_type)
234
+ ),
235
+ })
236
+
237
+ if assets_in_combo:
238
+ combinations.append({
239
+ "campaign.id": campaign_id_val,
240
+ "campaign.name": campaign_name,
241
+ "asset_group.id": ag_id,
242
+ "asset_group.name": ag_name,
243
+ "assets": assets_in_combo,
244
+ })
245
+
246
+ insights: list[str] = []
247
+ if not combinations:
248
+ insights.append(
249
+ "No top asset combinations found. This account has no Performance Max "
250
+ "campaigns, or the specified campaign_id does not match a PMax campaign."
251
+ )
252
+
253
+ return {
254
+ "top_combinations": combinations,
255
+ "total_combinations": len(combinations),
256
+ "insights": insights,
257
+ }