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.
- {adloop-0.2.0 → adloop-0.4.0}/PKG-INFO +36 -47
- {adloop-0.2.0 → adloop-0.4.0}/README.md +34 -45
- {adloop-0.2.0 → adloop-0.4.0}/pyproject.toml +2 -2
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/__init__.py +1 -1
- adloop-0.4.0/src/adloop/ads/pmax.py +257 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/read.py +205 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/write.py +41 -11
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/auth.py +73 -9
- adloop-0.4.0/src/adloop/bundled_credentials.json +12 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/cli.py +244 -74
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/config.py +2 -2
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/server.py +153 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/__main__.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/crossref.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.2.0 → adloop-0.4.0}/src/adloop/safety/preview.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
###
|
|
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
|
|
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 —
|
|
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` |
|
|
340
|
-
| `google` | `credentials_path` |
|
|
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 (
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
###
|
|
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
|
|
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 —
|
|
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` |
|
|
316
|
-
| `google` | `credentials_path` |
|
|
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 (
|
|
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 —
|
|
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
|
|
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.
|
|
4
|
-
description = "
|
|
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" }
|
|
@@ -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
|
+
}
|