adloop 0.8.1__tar.gz → 0.10.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.8.1 → adloop-0.10.0}/PKG-INFO +28 -31
- {adloop-0.8.1 → adloop-0.10.0}/README.md +26 -29
- {adloop-0.8.1 → adloop-0.10.0}/pyproject.toml +2 -2
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/__init__.py +10 -1
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/forecast.py +27 -9
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/gaql.py +38 -1
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/write.py +106 -12
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/auth.py +110 -45
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/cli.py +27 -30
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/config.py +1 -1
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/adloop.md +14 -0
- adloop-0.10.0/src/adloop/runtime.py +127 -0
- adloop-0.10.0/src/adloop/safety/audit.py +78 -0
- adloop-0.10.0/src/adloop/safety/preview.py +110 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/server.py +94 -99
- adloop-0.8.1/src/adloop/bundled_credentials.json +0 -13
- adloop-0.8.1/src/adloop/safety/audit.py +0 -40
- adloop-0.8.1/src/adloop/safety/preview.py +0 -58
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/__main__.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/crossref.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.8.1 → adloop-0.10.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.8.1 → adloop-0.10.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.10.0
|
|
4
|
+
Summary: The AI command center for Google Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
7
7
|
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# AdLoop
|
|
28
28
|
|
|
29
|
-
**
|
|
29
|
+
**The AI command center for Google Ads, GA4, and tracking code.**
|
|
30
30
|
|
|
31
31
|
[](https://pypi.org/project/adloop/)
|
|
32
32
|
[](LICENSE)
|
|
@@ -196,16 +196,22 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
196
196
|
|
|
197
197
|
## Setup
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
> **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
|
|
200
|
+
>
|
|
201
|
+
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
|
|
202
|
+
>
|
|
203
|
+
> *(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.)*
|
|
200
204
|
|
|
201
|
-
|
|
205
|
+
### Install
|
|
206
|
+
|
|
207
|
+
**From PyPI:**
|
|
202
208
|
|
|
203
209
|
```bash
|
|
204
210
|
pip install adloop
|
|
205
211
|
adloop init
|
|
206
212
|
```
|
|
207
213
|
|
|
208
|
-
**
|
|
214
|
+
**From source:**
|
|
209
215
|
|
|
210
216
|
```bash
|
|
211
217
|
git clone https://github.com/kLOsk/adloop.git
|
|
@@ -214,21 +220,17 @@ uv sync
|
|
|
214
220
|
uv run adloop init
|
|
215
221
|
```
|
|
216
222
|
|
|
217
|
-
|
|
223
|
+
### What `adloop init` does
|
|
218
224
|
|
|
219
|
-
|
|
220
|
-
> Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
|
|
221
|
-
>
|
|
222
|
-
> **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
|
|
225
|
+
The wizard walks you through:
|
|
223
226
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
227
|
+
1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
|
|
228
|
+
2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
229
|
+
3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
|
|
230
|
+
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
231
|
+
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
232
|
+
6. **Safety defaults** — budget cap and dry-run preference
|
|
233
|
+
7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
232
234
|
|
|
233
235
|
### Requirements
|
|
234
236
|
|
|
@@ -238,7 +240,7 @@ The wizard:
|
|
|
238
240
|
|
|
239
241
|
### Google Ads Developer Token
|
|
240
242
|
|
|
241
|
-
A developer token is **always required
|
|
243
|
+
A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
|
|
242
244
|
|
|
243
245
|
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
244
246
|
2. In the MCC, go to **Tools & Settings → API Center**
|
|
@@ -258,12 +260,9 @@ A developer token is **always required** — even when using AdLoop's built-in O
|
|
|
258
260
|
|
|
259
261
|
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.
|
|
260
262
|
|
|
261
|
-
###
|
|
262
|
-
|
|
263
|
-
<details>
|
|
264
|
-
<summary>Click to expand — only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
|
|
263
|
+
### Custom Google Cloud Project Setup
|
|
265
264
|
|
|
266
|
-
|
|
265
|
+
The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
|
|
267
266
|
|
|
268
267
|
#### Step 1 — Google Cloud Project
|
|
269
268
|
|
|
@@ -335,8 +334,6 @@ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` an
|
|
|
335
334
|
|
|
336
335
|
**Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
|
|
337
336
|
|
|
338
|
-
</details>
|
|
339
|
-
|
|
340
337
|
### Use It
|
|
341
338
|
|
|
342
339
|
Ask your AI assistant things like:
|
|
@@ -358,7 +355,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
358
355
|
| Section | Key | Default | Description |
|
|
359
356
|
|---------|-----|---------|-------------|
|
|
360
357
|
| `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
|
|
361
|
-
| `google` | `credentials_path` | *(empty
|
|
358
|
+
| `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
|
|
362
359
|
| `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
|
|
363
360
|
| `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
|
|
364
361
|
| `ads` | `developer_token` | — | Your Google Ads API developer token |
|
|
@@ -375,7 +372,7 @@ src/adloop/
|
|
|
375
372
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
376
373
|
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
377
374
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
378
|
-
├── auth.py # OAuth 2.0 flow (
|
|
375
|
+
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts
|
|
379
376
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
380
377
|
├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
|
|
381
378
|
├── tracking.py # Tracking validation + code generation tools
|
|
@@ -411,7 +408,7 @@ What's been shipped and what's next:
|
|
|
411
408
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
412
409
|
- **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
|
|
413
410
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
414
|
-
-
|
|
411
|
+
- **[AdLoop Cloud](https://getadloop.com)** — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
|
|
415
412
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
416
413
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
417
414
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
@@ -433,7 +430,7 @@ AdLoop runs entirely on your machine. No data is collected, stored, or transmitt
|
|
|
433
430
|
|
|
434
431
|
<div align="center">
|
|
435
432
|
|
|
436
|
-
**If AdLoop
|
|
433
|
+
**If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
|
|
437
434
|
|
|
438
435
|
Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
|
|
439
436
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# AdLoop
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**The AI command center for Google Ads, GA4, and tracking code.**
|
|
6
6
|
|
|
7
7
|
[](https://pypi.org/project/adloop/)
|
|
8
8
|
[](LICENSE)
|
|
@@ -172,16 +172,22 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
172
172
|
|
|
173
173
|
## Setup
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
> **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
|
|
176
|
+
>
|
|
177
|
+
> **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
|
|
178
|
+
>
|
|
179
|
+
> *(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.)*
|
|
176
180
|
|
|
177
|
-
|
|
181
|
+
### Install
|
|
182
|
+
|
|
183
|
+
**From PyPI:**
|
|
178
184
|
|
|
179
185
|
```bash
|
|
180
186
|
pip install adloop
|
|
181
187
|
adloop init
|
|
182
188
|
```
|
|
183
189
|
|
|
184
|
-
**
|
|
190
|
+
**From source:**
|
|
185
191
|
|
|
186
192
|
```bash
|
|
187
193
|
git clone https://github.com/kLOsk/adloop.git
|
|
@@ -190,21 +196,17 @@ uv sync
|
|
|
190
196
|
uv run adloop init
|
|
191
197
|
```
|
|
192
198
|
|
|
193
|
-
|
|
199
|
+
### What `adloop init` does
|
|
194
200
|
|
|
195
|
-
|
|
196
|
-
> Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
|
|
197
|
-
>
|
|
198
|
-
> **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
|
|
201
|
+
The wizard walks you through:
|
|
199
202
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
203
|
+
1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
|
|
204
|
+
2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
205
|
+
3. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
|
|
206
|
+
4. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
|
|
207
|
+
5. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
|
|
208
|
+
6. **Safety defaults** — budget cap and dry-run preference
|
|
209
|
+
7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
|
|
208
210
|
|
|
209
211
|
### Requirements
|
|
210
212
|
|
|
@@ -214,7 +216,7 @@ The wizard:
|
|
|
214
216
|
|
|
215
217
|
### Google Ads Developer Token
|
|
216
218
|
|
|
217
|
-
A developer token is **always required
|
|
219
|
+
A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
|
|
218
220
|
|
|
219
221
|
1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
|
|
220
222
|
2. In the MCC, go to **Tools & Settings → API Center**
|
|
@@ -234,12 +236,9 @@ A developer token is **always required** — even when using AdLoop's built-in O
|
|
|
234
236
|
|
|
235
237
|
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.
|
|
236
238
|
|
|
237
|
-
###
|
|
238
|
-
|
|
239
|
-
<details>
|
|
240
|
-
<summary>Click to expand — only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
|
|
239
|
+
### Custom Google Cloud Project Setup
|
|
241
240
|
|
|
242
|
-
|
|
241
|
+
The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
|
|
243
242
|
|
|
244
243
|
#### Step 1 — Google Cloud Project
|
|
245
244
|
|
|
@@ -311,8 +310,6 @@ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` an
|
|
|
311
310
|
|
|
312
311
|
**Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai.
|
|
313
312
|
|
|
314
|
-
</details>
|
|
315
|
-
|
|
316
313
|
### Use It
|
|
317
314
|
|
|
318
315
|
Ask your AI assistant things like:
|
|
@@ -334,7 +331,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
|
|
|
334
331
|
| Section | Key | Default | Description |
|
|
335
332
|
|---------|-----|---------|-------------|
|
|
336
333
|
| `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
|
|
337
|
-
| `google` | `credentials_path` | *(empty
|
|
334
|
+
| `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
|
|
338
335
|
| `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
|
|
339
336
|
| `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
|
|
340
337
|
| `ads` | `developer_token` | — | Your Google Ads API developer token |
|
|
@@ -351,7 +348,7 @@ src/adloop/
|
|
|
351
348
|
├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
|
|
352
349
|
├── server.py # FastMCP server — 43 tool registrations with safety annotations
|
|
353
350
|
├── config.py # Config loader (~/.adloop/config.yaml)
|
|
354
|
-
├── auth.py # OAuth 2.0 flow (
|
|
351
|
+
├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts
|
|
355
352
|
├── cli.py # Interactive 'adloop init' setup wizard
|
|
356
353
|
├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
|
|
357
354
|
├── tracking.py # Tracking validation + code generation tools
|
|
@@ -387,7 +384,7 @@ What's been shipped and what's next:
|
|
|
387
384
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
388
385
|
- **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
|
|
389
386
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
390
|
-
-
|
|
387
|
+
- **[AdLoop Cloud](https://getadloop.com)** — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
|
|
391
388
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
392
389
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
393
390
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
@@ -409,7 +406,7 @@ AdLoop runs entirely on your machine. No data is collected, stored, or transmitt
|
|
|
409
406
|
|
|
410
407
|
<div align="center">
|
|
411
408
|
|
|
412
|
-
**If AdLoop
|
|
409
|
+
**If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
|
|
413
410
|
|
|
414
411
|
Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
|
|
415
412
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.10.0"
|
|
4
|
+
description = "The AI command center for Google Ads, GA4, and tracking code."
|
|
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.
|
|
5
|
+
__version__ = "0.9.0"
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def main() -> None:
|
|
@@ -42,6 +42,15 @@ def main() -> None:
|
|
|
42
42
|
sys.exit(130)
|
|
43
43
|
return
|
|
44
44
|
|
|
45
|
+
# Process-global side effects (signal handlers, heartbeat thread, and
|
|
46
|
+
# the stdio cancellation-race monkeypatch) are deliberately installed
|
|
47
|
+
# here — in the stdio entry point — rather than at adloop.server import
|
|
48
|
+
# time, so embedding the server in an ASGI app stays side-effect-free.
|
|
49
|
+
from adloop import _mcp_patches, diagnostics
|
|
50
|
+
|
|
51
|
+
diagnostics.install()
|
|
52
|
+
_mcp_patches.install()
|
|
53
|
+
|
|
45
54
|
from adloop.server import mcp
|
|
46
55
|
|
|
47
56
|
mcp.run()
|
|
@@ -98,30 +98,43 @@ def estimate_budget(
|
|
|
98
98
|
response = kp_service.generate_keyword_forecast_metrics(request=request)
|
|
99
99
|
metrics = response.campaign_forecast_metrics
|
|
100
100
|
|
|
101
|
+
# KeywordForecastMetrics fields are ``optional`` in v23, so the SDK
|
|
102
|
+
# returns ``None`` for unset fields and the actual integer (including
|
|
103
|
+
# 0) otherwise. Falsy checks like ``int(v) if v else None`` would
|
|
104
|
+
# silently map a real 0-click or 0-cost forecast to None and the
|
|
105
|
+
# caller couldn't tell "no data" apart from "data says zero" — same
|
|
106
|
+
# bug class as discover_keywords (issue Bug 2). Use ``is not None``
|
|
107
|
+
# throughout and the shared ``_micros_to_currency`` helper for the
|
|
108
|
+
# micros→currency conversions.
|
|
101
109
|
clicks = getattr(metrics, "clicks", None)
|
|
102
110
|
impressions = getattr(metrics, "impressions", None)
|
|
103
111
|
avg_cpc_micros = getattr(metrics, "average_cpc_micros", None)
|
|
104
112
|
cost_micros = getattr(metrics, "cost_micros", None)
|
|
105
113
|
ctr = getattr(metrics, "click_through_rate", None)
|
|
106
114
|
|
|
107
|
-
total_cost =
|
|
108
|
-
avg_cpc =
|
|
115
|
+
total_cost = _micros_to_currency(cost_micros)
|
|
116
|
+
avg_cpc = _micros_to_currency(avg_cpc_micros)
|
|
109
117
|
|
|
110
118
|
days = max(forecast_days, 1)
|
|
111
119
|
daily = {
|
|
112
|
-
"clicks": round(clicks / days, 1) if clicks else None,
|
|
113
|
-
"impressions":
|
|
114
|
-
|
|
120
|
+
"clicks": round(clicks / days, 1) if clicks is not None else None,
|
|
121
|
+
"impressions": (
|
|
122
|
+
round(impressions / days, 1) if impressions is not None else None
|
|
123
|
+
),
|
|
124
|
+
"cost": round(total_cost / days, 2) if total_cost is not None else None,
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
insights = []
|
|
128
|
+
# Only emit the headline insight when there's actually something to
|
|
129
|
+
# report (real clicks AND real cost) — a zero-click forecast gets its
|
|
130
|
+
# own dedicated insight below, and a None forecast shouldn't trigger
|
|
131
|
+
# either path.
|
|
118
132
|
if total_cost is not None and clicks is not None and clicks > 0:
|
|
119
|
-
effective_cpa_budget = total_cost / clicks * 10
|
|
120
133
|
insights.append(
|
|
121
134
|
f"Estimated {clicks:.0f} clicks over {forecast_days} days at "
|
|
122
135
|
f"~{avg_cpc} avg CPC. Total estimated cost: {total_cost:.2f}."
|
|
123
136
|
)
|
|
124
|
-
if daily_budget > 0 and daily["cost"] is not None:
|
|
137
|
+
if daily_budget > 0 and daily["cost"] is not None and daily["cost"] > 0:
|
|
125
138
|
if daily_budget < daily["cost"]:
|
|
126
139
|
capture_pct = round(daily_budget / daily["cost"] * 100)
|
|
127
140
|
insights.append(
|
|
@@ -134,7 +147,12 @@ def estimate_budget(
|
|
|
134
147
|
f"most available traffic (estimated daily cost: {daily['cost']:.2f})."
|
|
135
148
|
)
|
|
136
149
|
|
|
137
|
-
if
|
|
150
|
+
if (
|
|
151
|
+
impressions is not None
|
|
152
|
+
and clicks is not None
|
|
153
|
+
and impressions > 0
|
|
154
|
+
and clicks == 0
|
|
155
|
+
):
|
|
138
156
|
insights.append(
|
|
139
157
|
"Forecast shows impressions but zero clicks — keywords may be too "
|
|
140
158
|
"generic or CPCs too low for competitive positions."
|
|
@@ -149,7 +167,7 @@ def estimate_budget(
|
|
|
149
167
|
"estimated_impressions": impressions,
|
|
150
168
|
"estimated_cost": total_cost,
|
|
151
169
|
"estimated_avg_cpc": avg_cpc,
|
|
152
|
-
"estimated_ctr": round(ctr, 4) if ctr else None,
|
|
170
|
+
"estimated_ctr": round(ctr, 4) if ctr is not None else None,
|
|
153
171
|
"daily_estimates": daily,
|
|
154
172
|
"keywords_used": len([kw for kw in keywords if kw.get("text")]),
|
|
155
173
|
"insights": insights,
|
|
@@ -89,7 +89,29 @@ _GAQL_ERROR_HINTS = {
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def _parse_gaql_error(exc: Exception) -> str:
|
|
92
|
-
"""Extract a human-readable message from Google Ads gRPC errors.
|
|
92
|
+
"""Extract a human-readable message from Google Ads gRPC errors.
|
|
93
|
+
|
|
94
|
+
Prefers the structured ``failure.errors[]`` a GoogleAdsException
|
|
95
|
+
carries — Google's own message names the exact field/clause at
|
|
96
|
+
fault (e.g. PROHIBITED_FIELD_IN_SELECT_CLAUSE with the field name),
|
|
97
|
+
which is far more actionable than a generic hint. Known error codes
|
|
98
|
+
still get the hint appended as a suffix.
|
|
99
|
+
"""
|
|
100
|
+
failure = getattr(exc, "failure", None)
|
|
101
|
+
errors = getattr(failure, "errors", None) if failure is not None else None
|
|
102
|
+
if errors:
|
|
103
|
+
parts = []
|
|
104
|
+
for err in errors:
|
|
105
|
+
code = str(getattr(err, "error_code", "") or "").strip()
|
|
106
|
+
message = str(getattr(err, "message", "") or "").strip()
|
|
107
|
+
parts.append(" — ".join(p for p in (code, message) if p))
|
|
108
|
+
detail = " | ".join(p for p in parts if p)
|
|
109
|
+
if detail:
|
|
110
|
+
for known, hint in _GAQL_ERROR_HINTS.items():
|
|
111
|
+
if known in detail:
|
|
112
|
+
return f"{detail} (hint: {hint})"
|
|
113
|
+
return detail
|
|
114
|
+
|
|
93
115
|
raw = str(exc)
|
|
94
116
|
for code, hint in _GAQL_ERROR_HINTS.items():
|
|
95
117
|
if code in raw:
|
|
@@ -137,6 +159,21 @@ def _to_python(obj: object) -> object:
|
|
|
137
159
|
# AdTextAsset and similar message types
|
|
138
160
|
if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str):
|
|
139
161
|
return obj.text
|
|
162
|
+
# Nested proto-plus messages (targeting settings, criteria, ...) —
|
|
163
|
+
# serialize to a dict instead of collapsing to their str() repr.
|
|
164
|
+
try:
|
|
165
|
+
import proto
|
|
166
|
+
|
|
167
|
+
if isinstance(obj, proto.Message):
|
|
168
|
+
return type(obj).to_dict(
|
|
169
|
+
obj, preserving_proto_field_name=True, use_integers_for_enums=False
|
|
170
|
+
)
|
|
171
|
+
except ImportError:
|
|
172
|
+
pass
|
|
173
|
+
if hasattr(obj, "DESCRIPTOR"):
|
|
174
|
+
from google.protobuf.json_format import MessageToDict
|
|
175
|
+
|
|
176
|
+
return MessageToDict(obj, preserving_proto_field_name=True)
|
|
140
177
|
return str(obj)
|
|
141
178
|
|
|
142
179
|
|
|
@@ -71,6 +71,79 @@ def _normalize_rsa_assets(items: list) -> list[dict]:
|
|
|
71
71
|
# ---------------------------------------------------------------------------
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _ssrf_error(url: str) -> str | None:
|
|
75
|
+
"""Reject URLs that would make the validation fetch reach non-public hosts.
|
|
76
|
+
|
|
77
|
+
User-supplied URLs are fetched from the machine running AdLoop; on a
|
|
78
|
+
hosted multi-tenant server that request originates inside our network,
|
|
79
|
+
so private/loopback/link-local/metadata targets must be refused. Ads
|
|
80
|
+
pointing at such addresses could never serve anyway. Returns an error
|
|
81
|
+
string, or None if the URL looks safe to fetch.
|
|
82
|
+
|
|
83
|
+
Note: the fetch re-resolves DNS after this check, so a hostile DNS
|
|
84
|
+
server could still rebind between check and fetch — acceptable here
|
|
85
|
+
because the fetch result is only an up/down signal, never returned
|
|
86
|
+
to the caller.
|
|
87
|
+
"""
|
|
88
|
+
import ipaddress
|
|
89
|
+
import socket
|
|
90
|
+
from urllib.parse import urlparse
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
parsed = urlparse(url)
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
return f"unparseable URL: {e}"
|
|
96
|
+
|
|
97
|
+
if parsed.scheme not in ("http", "https"):
|
|
98
|
+
return f"unsupported URL scheme '{parsed.scheme}' (only http/https)"
|
|
99
|
+
|
|
100
|
+
host = parsed.hostname
|
|
101
|
+
if not host:
|
|
102
|
+
return "URL has no hostname"
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
addresses = [ipaddress.ip_address(host)]
|
|
106
|
+
except ValueError:
|
|
107
|
+
try:
|
|
108
|
+
addr_info = socket.getaddrinfo(host, None)
|
|
109
|
+
except OSError as e:
|
|
110
|
+
return f"hostname does not resolve: {e}"
|
|
111
|
+
addresses = [
|
|
112
|
+
ipaddress.ip_address(info[4][0]) for info in addr_info
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
for addr in addresses:
|
|
116
|
+
if (
|
|
117
|
+
addr.is_private
|
|
118
|
+
or addr.is_loopback
|
|
119
|
+
or addr.is_link_local
|
|
120
|
+
or addr.is_multicast
|
|
121
|
+
or addr.is_reserved
|
|
122
|
+
or addr.is_unspecified
|
|
123
|
+
):
|
|
124
|
+
return (
|
|
125
|
+
f"URL resolves to a non-public address ({addr}) — "
|
|
126
|
+
"refusing to fetch"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_public_only_opener():
|
|
133
|
+
"""Return a urllib opener that re-checks every redirect hop for SSRF."""
|
|
134
|
+
import urllib.error
|
|
135
|
+
import urllib.request
|
|
136
|
+
|
|
137
|
+
class _PublicOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
138
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
139
|
+
err = _ssrf_error(newurl)
|
|
140
|
+
if err is not None:
|
|
141
|
+
raise urllib.error.URLError(f"redirect blocked: {err}")
|
|
142
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
143
|
+
|
|
144
|
+
return urllib.request.build_opener(_PublicOnlyRedirectHandler)
|
|
145
|
+
|
|
146
|
+
|
|
74
147
|
def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
|
|
75
148
|
"""Check that each URL returns a 2xx/3xx status.
|
|
76
149
|
|
|
@@ -79,14 +152,20 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
|
|
|
79
152
|
import urllib.request
|
|
80
153
|
import urllib.error
|
|
81
154
|
|
|
155
|
+
opener = _build_public_only_opener()
|
|
156
|
+
|
|
82
157
|
results = {}
|
|
83
158
|
for url in urls:
|
|
84
159
|
if not url:
|
|
85
160
|
continue
|
|
161
|
+
ssrf = _ssrf_error(url)
|
|
162
|
+
if ssrf is not None:
|
|
163
|
+
results[url] = ssrf
|
|
164
|
+
continue
|
|
86
165
|
try:
|
|
87
166
|
req = urllib.request.Request(url, method="HEAD")
|
|
88
167
|
req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
|
|
89
|
-
resp =
|
|
168
|
+
resp = opener.open(req, timeout=timeout)
|
|
90
169
|
if resp.status >= 400:
|
|
91
170
|
results[url] = f"HTTP {resp.status}"
|
|
92
171
|
else:
|
|
@@ -97,7 +176,7 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
|
|
|
97
176
|
try:
|
|
98
177
|
req = urllib.request.Request(url, method="GET")
|
|
99
178
|
req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
|
|
100
|
-
resp =
|
|
179
|
+
resp = opener.open(req, timeout=timeout)
|
|
101
180
|
if resp.status >= 400:
|
|
102
181
|
results[url] = f"HTTP {resp.status}"
|
|
103
182
|
else:
|
|
@@ -1221,9 +1300,19 @@ def draft_image_assets(
|
|
|
1221
1300
|
image_paths: list[str] | None = None,
|
|
1222
1301
|
) -> dict:
|
|
1223
1302
|
"""Draft campaign image assets from local files."""
|
|
1303
|
+
from adloop.runtime import deployment_mode
|
|
1224
1304
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
1225
1305
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
1226
1306
|
|
|
1307
|
+
if deployment_mode() == "server":
|
|
1308
|
+
return {
|
|
1309
|
+
"error": (
|
|
1310
|
+
"draft_image_assets reads image files from the local "
|
|
1311
|
+
"filesystem and is not available on the hosted server. "
|
|
1312
|
+
"Use the self-hosted AdLoop MCP server for image assets."
|
|
1313
|
+
)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1227
1316
|
try:
|
|
1228
1317
|
check_blocked_operation("create_image_assets", config.safety)
|
|
1229
1318
|
except SafetyViolation as e:
|
|
@@ -3146,11 +3235,14 @@ def _apply_attach_shared_set_to_campaigns(
|
|
|
3146
3235
|
css.shared_set = shared_set_resource
|
|
3147
3236
|
operations.append(op)
|
|
3148
3237
|
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3238
|
+
# CampaignSharedSetService.mutate_campaign_shared_sets does NOT accept a
|
|
3239
|
+
# flattened ``partial_failure`` kwarg (unlike GoogleAdsService.mutate);
|
|
3240
|
+
# it must be set on the request object.
|
|
3241
|
+
request = client.get_type("MutateCampaignSharedSetsRequest")
|
|
3242
|
+
request.customer_id = cid
|
|
3243
|
+
request.operations.extend(operations)
|
|
3244
|
+
request.partial_failure = True
|
|
3245
|
+
response = css_service.mutate_campaign_shared_sets(request=request)
|
|
3154
3246
|
|
|
3155
3247
|
pf_error = getattr(response, "partial_failure_error", None)
|
|
3156
3248
|
per_op_errors = _parse_partial_failure_per_op(client, pf_error)
|
|
@@ -3213,11 +3305,13 @@ def _apply_detach_shared_set_from_campaigns(
|
|
|
3213
3305
|
)
|
|
3214
3306
|
operations.append(op)
|
|
3215
3307
|
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
)
|
|
3308
|
+
# partial_failure must be set on the request object — the flattened
|
|
3309
|
+
# kwarg is not supported by CampaignSharedSetService (see attach above).
|
|
3310
|
+
request = client.get_type("MutateCampaignSharedSetsRequest")
|
|
3311
|
+
request.customer_id = cid
|
|
3312
|
+
request.operations.extend(operations)
|
|
3313
|
+
request.partial_failure = True
|
|
3314
|
+
response = css_service.mutate_campaign_shared_sets(request=request)
|
|
3221
3315
|
|
|
3222
3316
|
pf_error = getattr(response, "partial_failure_error", None)
|
|
3223
3317
|
per_op_errors = _parse_partial_failure_per_op(client, pf_error)
|