adloop 0.1.0__tar.gz → 0.2.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.1.0/README.md → adloop-0.2.0/PKG-INFO +65 -19
- adloop-0.1.0/PKG-INFO → adloop-0.2.0/README.md +41 -39
- {adloop-0.1.0 → adloop-0.2.0}/pyproject.toml +7 -1
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/__init__.py +1 -1
- adloop-0.2.0/src/adloop/ads/currency.py +79 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ads/gaql.py +10 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ads/read.py +15 -7
- adloop-0.2.0/src/adloop/ads/write.py +2413 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/crossref.py +6 -2
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/server.py +307 -25
- adloop-0.1.0/src/adloop/ads/write.py +0 -950
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/__main__.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/auth.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/cli.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/config.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.1.0 → adloop-0.2.0}/src/adloop/tracking.py +0 -0
|
@@ -1,9 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: adloop
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server connecting Google Ads + GA4 + codebase into one AI-driven feedback loop
|
|
5
|
+
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
|
+
Author: Daniel Klose
|
|
7
|
+
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
+
Requires-Dist: google-ads>=29.0.0
|
|
11
|
+
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
|
+
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
|
+
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Project-URL: Homepage, https://github.com/kLOsk/adloop
|
|
19
|
+
Project-URL: Repository, https://github.com/kLOsk/adloop
|
|
20
|
+
Project-URL: Issues, https://github.com/kLOsk/adloop/issues
|
|
21
|
+
Project-URL: Changelog, https://github.com/kLOsk/adloop/releases
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
1
25
|
<div align="center">
|
|
2
26
|
|
|
3
27
|
# AdLoop
|
|
4
28
|
|
|
5
|
-
**
|
|
29
|
+
**Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.**
|
|
6
30
|
|
|
31
|
+
[](https://pypi.org/project/adloop/)
|
|
7
32
|
[](LICENSE)
|
|
8
33
|
[](https://www.python.org/downloads/)
|
|
9
34
|
[](https://modelcontextprotocol.io)
|
|
@@ -11,25 +36,39 @@
|
|
|
11
36
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
12
37
|
[](https://github.com/kLOsk/adloop)
|
|
13
38
|
|
|
14
|
-
|
|
39
|
+
An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
|
|
15
40
|
|
|
16
|
-
|
|
41
|
+
`pip install adloop`
|
|
17
42
|
|
|
18
43
|
</div>
|
|
19
44
|
|
|
20
45
|
---
|
|
21
46
|
|
|
22
|
-
##
|
|
47
|
+
## What It Solves
|
|
48
|
+
|
|
49
|
+
AdLoop exists because managing Google Ads alongside your code is a mess. These are the specific problems it handles:
|
|
50
|
+
|
|
51
|
+
- **"My conversions dropped and I don't know why."** AdLoop cross-references Ads clicks, GA4 sessions, and conversion events in one query. It detects whether the gap is from GDPR consent rejection, broken tracking, or an actual landing page problem — before you waste hours checking each dashboard separately.
|
|
52
|
+
|
|
53
|
+
- **"I'm wasting ad spend on irrelevant searches."** Pull your search terms report, identify the junk, and add negative keywords — all from a single conversation in your IDE. No context-switching to the Ads UI.
|
|
54
|
+
|
|
55
|
+
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
23
56
|
|
|
24
|
-
|
|
57
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
25
58
|
|
|
26
|
-
|
|
59
|
+
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
27
60
|
|
|
28
|
-
|
|
61
|
+
- **"I don't know if my EU consent setup is causing data gaps."** In Europe, 30-70% of users reject analytics cookies. AdLoop accounts for this automatically — it won't diagnose a normal GDPR consent gap as broken tracking.
|
|
29
62
|
|
|
30
|
-
##
|
|
63
|
+
## Built From Real Usage
|
|
64
|
+
|
|
65
|
+
Every tool exists because of an actual problem hit while running real Google Ads campaigns. The cross-reference tools exist because we kept manually asking the AI to "get Ads data, then get GA4 data, then compare them" — so we automated the join. The Broad Match + Manual CPC safety rule exists because the AI once created that exact combination and wasted budget. The GDPR consent awareness exists because the AI kept diagnosing normal EU cookie rejection as broken tracking.
|
|
66
|
+
|
|
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.
|
|
31
68
|
|
|
32
|
-
|
|
69
|
+
## All 33 Tools
|
|
70
|
+
|
|
71
|
+
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
|
|
33
72
|
|
|
34
73
|
### Diagnostics
|
|
35
74
|
|
|
@@ -87,8 +126,14 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
87
126
|
|
|
88
127
|
| Tool | What It Does |
|
|
89
128
|
|------|-------------|
|
|
90
|
-
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords.
|
|
129
|
+
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
130
|
+
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
131
|
+
| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
|
|
132
|
+
| `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
|
|
91
133
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
134
|
+
| `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
|
|
135
|
+
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
136
|
+
| `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
|
|
92
137
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
93
138
|
| `add_negative_keywords` | Propose negative keywords to reduce wasted spend |
|
|
94
139
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
@@ -143,6 +188,15 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
143
188
|
|
|
144
189
|
### Quick Start (Recommended)
|
|
145
190
|
|
|
191
|
+
**Option A — Install from PyPI:**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pip install adloop
|
|
195
|
+
adloop init
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Option B — Install from source:**
|
|
199
|
+
|
|
146
200
|
```bash
|
|
147
201
|
git clone https://github.com/kLOsk/adloop.git
|
|
148
202
|
cd adloop
|
|
@@ -320,14 +374,6 @@ src/adloop/
|
|
|
320
374
|
└── audit.py # Mutation audit logging
|
|
321
375
|
```
|
|
322
376
|
|
|
323
|
-
## Built From Real Usage
|
|
324
|
-
|
|
325
|
-
AdLoop isn't a theoretical tool — it's built from running real Google Ads campaigns and hitting real problems. Every tool exists because of an actual situation: needing to diagnose a conversion drop without leaving the IDE, wanting to bulk-add negative keywords after seeing wasted spend in the search terms report, drafting ad variants that match a landing page the AI just helped rewrite.
|
|
326
|
-
|
|
327
|
-
The cross-reference tools exist because we kept manually asking the AI to "get Ads data, then get GA4 data, then compare them" — so we automated the join. The Broad Match + Manual CPC safety rule exists because the AI once created that exact combination and wasted budget. The GDPR consent awareness exists because the AI kept diagnosing normal EU cookie rejection as broken tracking.
|
|
328
|
-
|
|
329
|
-
The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **that's exactly the kind of feedback that shapes what gets built next.** 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.
|
|
330
|
-
|
|
331
377
|
## Roadmap
|
|
332
378
|
|
|
333
379
|
What's been shipped and what's next:
|
|
@@ -339,7 +385,7 @@ What's been shipped and what's next:
|
|
|
339
385
|
- ~~Budget estimation via Keyword Planner~~ ✓
|
|
340
386
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
341
387
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
342
|
-
-
|
|
388
|
+
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
343
389
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
344
390
|
- **Video walkthrough**
|
|
345
391
|
|
|
@@ -1,29 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: adloop
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: MCP server connecting Google Ads + GA4 + codebase into one AI-driven feedback loop
|
|
5
|
-
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
|
-
Author: Daniel Klose
|
|
7
|
-
Author-email: Daniel Klose <info@daniel-klose.com>
|
|
8
|
-
License: MIT
|
|
9
|
-
Requires-Dist: fastmcp>=3.0.0
|
|
10
|
-
Requires-Dist: google-ads>=29.0.0
|
|
11
|
-
Requires-Dist: google-analytics-data>=0.20.0
|
|
12
|
-
Requires-Dist: google-analytics-admin>=0.27.0
|
|
13
|
-
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
14
|
-
Requires-Dist: pyyaml>=6.0
|
|
15
|
-
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
16
|
-
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
17
|
-
Requires-Python: >=3.11
|
|
18
|
-
Provides-Extra: dev
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
|
|
21
1
|
<div align="center">
|
|
22
2
|
|
|
23
3
|
# AdLoop
|
|
24
4
|
|
|
25
|
-
**
|
|
5
|
+
**Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.**
|
|
26
6
|
|
|
7
|
+
[](https://pypi.org/project/adloop/)
|
|
27
8
|
[](LICENSE)
|
|
28
9
|
[](https://www.python.org/downloads/)
|
|
29
10
|
[](https://modelcontextprotocol.io)
|
|
@@ -31,25 +12,39 @@ Description-Content-Type: text/markdown
|
|
|
31
12
|
[](https://developers.google.com/analytics/devguides/reporting/data/v1)
|
|
32
13
|
[](https://github.com/kLOsk/adloop)
|
|
33
14
|
|
|
34
|
-
|
|
15
|
+
An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
|
|
35
16
|
|
|
36
|
-
|
|
17
|
+
`pip install adloop`
|
|
37
18
|
|
|
38
19
|
</div>
|
|
39
20
|
|
|
40
21
|
---
|
|
41
22
|
|
|
42
|
-
##
|
|
23
|
+
## What It Solves
|
|
24
|
+
|
|
25
|
+
AdLoop exists because managing Google Ads alongside your code is a mess. These are the specific problems it handles:
|
|
26
|
+
|
|
27
|
+
- **"My conversions dropped and I don't know why."** AdLoop cross-references Ads clicks, GA4 sessions, and conversion events in one query. It detects whether the gap is from GDPR consent rejection, broken tracking, or an actual landing page problem — before you waste hours checking each dashboard separately.
|
|
28
|
+
|
|
29
|
+
- **"I'm wasting ad spend on irrelevant searches."** Pull your search terms report, identify the junk, and add negative keywords — all from a single conversation in your IDE. No context-switching to the Ads UI.
|
|
30
|
+
|
|
31
|
+
- **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
- **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
|
|
45
34
|
|
|
46
|
-
|
|
35
|
+
- **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
|
|
47
36
|
|
|
48
|
-
|
|
37
|
+
- **"I don't know if my EU consent setup is causing data gaps."** In Europe, 30-70% of users reject analytics cookies. AdLoop accounts for this automatically — it won't diagnose a normal GDPR consent gap as broken tracking.
|
|
49
38
|
|
|
50
|
-
##
|
|
39
|
+
## Built From Real Usage
|
|
40
|
+
|
|
41
|
+
Every tool exists because of an actual problem hit while running real Google Ads campaigns. The cross-reference tools exist because we kept manually asking the AI to "get Ads data, then get GA4 data, then compare them" — so we automated the join. The Broad Match + Manual CPC safety rule exists because the AI once created that exact combination and wasted budget. The GDPR consent awareness exists because the AI kept diagnosing normal EU cookie rejection as broken tracking.
|
|
42
|
+
|
|
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.
|
|
51
44
|
|
|
52
|
-
|
|
45
|
+
## All 33 Tools
|
|
46
|
+
|
|
47
|
+
> **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
|
|
53
48
|
|
|
54
49
|
### Diagnostics
|
|
55
50
|
|
|
@@ -107,8 +102,14 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
107
102
|
|
|
108
103
|
| Tool | What It Does |
|
|
109
104
|
|------|-------------|
|
|
110
|
-
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords.
|
|
105
|
+
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
106
|
+
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
107
|
+
| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
|
|
108
|
+
| `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
|
|
111
109
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
110
|
+
| `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
|
|
111
|
+
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
112
|
+
| `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
|
|
112
113
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
|
|
113
114
|
| `add_negative_keywords` | Propose negative keywords to reduce wasted spend |
|
|
114
115
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
@@ -163,6 +164,15 @@ AdLoop manages real ad spend, so safety is not optional.
|
|
|
163
164
|
|
|
164
165
|
### Quick Start (Recommended)
|
|
165
166
|
|
|
167
|
+
**Option A — Install from PyPI:**
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pip install adloop
|
|
171
|
+
adloop init
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Option B — Install from source:**
|
|
175
|
+
|
|
166
176
|
```bash
|
|
167
177
|
git clone https://github.com/kLOsk/adloop.git
|
|
168
178
|
cd adloop
|
|
@@ -340,14 +350,6 @@ src/adloop/
|
|
|
340
350
|
└── audit.py # Mutation audit logging
|
|
341
351
|
```
|
|
342
352
|
|
|
343
|
-
## Built From Real Usage
|
|
344
|
-
|
|
345
|
-
AdLoop isn't a theoretical tool — it's built from running real Google Ads campaigns and hitting real problems. Every tool exists because of an actual situation: needing to diagnose a conversion drop without leaving the IDE, wanting to bulk-add negative keywords after seeing wasted spend in the search terms report, drafting ad variants that match a landing page the AI just helped rewrite.
|
|
346
|
-
|
|
347
|
-
The cross-reference tools exist because we kept manually asking the AI to "get Ads data, then get GA4 data, then compare them" — so we automated the join. The Broad Match + Manual CPC safety rule exists because the AI once created that exact combination and wasted budget. The GDPR consent awareness exists because the AI kept diagnosing normal EU cookie rejection as broken tracking.
|
|
348
|
-
|
|
349
|
-
The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **that's exactly the kind of feedback that shapes what gets built next.** 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.
|
|
350
|
-
|
|
351
353
|
## Roadmap
|
|
352
354
|
|
|
353
355
|
What's been shipped and what's next:
|
|
@@ -359,7 +361,7 @@ What's been shipped and what's next:
|
|
|
359
361
|
- ~~Budget estimation via Keyword Planner~~ ✓
|
|
360
362
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
361
363
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
362
|
-
-
|
|
364
|
+
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
363
365
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
364
366
|
- **Video walkthrough**
|
|
365
367
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "adloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "MCP server connecting Google Ads + GA4 + codebase into one AI-driven feedback loop"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -18,6 +18,12 @@ dependencies = [
|
|
|
18
18
|
"pyyaml>=6.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/kLOsk/adloop"
|
|
23
|
+
Repository = "https://github.com/kLOsk/adloop"
|
|
24
|
+
Issues = "https://github.com/kLOsk/adloop/issues"
|
|
25
|
+
Changelog = "https://github.com/kLOsk/adloop/releases"
|
|
26
|
+
|
|
21
27
|
[project.scripts]
|
|
22
28
|
adloop = "adloop:main"
|
|
23
29
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Currency detection and formatting — auto-detect from Google Ads account."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from adloop.config import AdLoopConfig
|
|
9
|
+
|
|
10
|
+
_CURRENCY_SYMBOLS: dict[str, str] = {
|
|
11
|
+
"EUR": "\u20ac",
|
|
12
|
+
"USD": "$",
|
|
13
|
+
"GBP": "\u00a3",
|
|
14
|
+
"PLN": "z\u0142",
|
|
15
|
+
"CHF": "CHF",
|
|
16
|
+
"CZK": "K\u010d",
|
|
17
|
+
"SEK": "kr",
|
|
18
|
+
"NOK": "kr",
|
|
19
|
+
"DKK": "kr",
|
|
20
|
+
"HUF": "Ft",
|
|
21
|
+
"RON": "lei",
|
|
22
|
+
"BGN": "лв",
|
|
23
|
+
"HRK": "kn",
|
|
24
|
+
"TRY": "\u20ba",
|
|
25
|
+
"JPY": "\u00a5",
|
|
26
|
+
"CNY": "\u00a5",
|
|
27
|
+
"KRW": "\u20a9",
|
|
28
|
+
"INR": "\u20b9",
|
|
29
|
+
"BRL": "R$",
|
|
30
|
+
"AUD": "A$",
|
|
31
|
+
"CAD": "C$",
|
|
32
|
+
"NZD": "NZ$",
|
|
33
|
+
"MXN": "MX$",
|
|
34
|
+
"ARS": "ARS",
|
|
35
|
+
"ZAR": "R",
|
|
36
|
+
"RUB": "\u20bd",
|
|
37
|
+
"UAH": "\u20b4",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Module-level cache: customer_id -> currency_code (one API call per session)
|
|
41
|
+
_cache: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_currency_code(config: AdLoopConfig, customer_id: str) -> str:
|
|
45
|
+
"""Detect the account's currency via ``customer.currency_code``.
|
|
46
|
+
|
|
47
|
+
Result is cached per *customer_id* for the lifetime of the server process.
|
|
48
|
+
Falls back to ``"EUR"`` on any error.
|
|
49
|
+
"""
|
|
50
|
+
from adloop.ads.client import normalize_customer_id
|
|
51
|
+
|
|
52
|
+
cid = normalize_customer_id(customer_id)
|
|
53
|
+
if cid in _cache:
|
|
54
|
+
return _cache[cid]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from adloop.ads.gaql import execute_query
|
|
58
|
+
|
|
59
|
+
rows = execute_query(
|
|
60
|
+
config, customer_id, "SELECT customer.currency_code FROM customer LIMIT 1"
|
|
61
|
+
)
|
|
62
|
+
code = (rows[0].get("customer.currency_code", "EUR") if rows else "EUR") or "EUR"
|
|
63
|
+
except Exception:
|
|
64
|
+
code = "EUR"
|
|
65
|
+
|
|
66
|
+
_cache[cid] = code
|
|
67
|
+
return code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_currency(amount: float, currency_code: str) -> str:
|
|
71
|
+
"""Format *amount* with the correct currency symbol/code.
|
|
72
|
+
|
|
73
|
+
Examples::
|
|
74
|
+
|
|
75
|
+
format_currency(42.50, "PLN") -> "42.50 PLN"
|
|
76
|
+
format_currency(42.50, "EUR") -> "42.50 EUR"
|
|
77
|
+
format_currency(42.50, "USD") -> "42.50 USD"
|
|
78
|
+
"""
|
|
79
|
+
return f"{amount:.2f} {currency_code}"
|
|
@@ -63,6 +63,16 @@ def run_gaql(
|
|
|
63
63
|
# ---------------------------------------------------------------------------
|
|
64
64
|
|
|
65
65
|
_GAQL_ERROR_HINTS = {
|
|
66
|
+
"DEVELOPER_TOKEN_NOT_APPROVED": (
|
|
67
|
+
"Your Google Ads developer token is only approved for test accounts. "
|
|
68
|
+
"Apply for Basic or Standard access in the Google Ads API Center, "
|
|
69
|
+
"or use a test account."
|
|
70
|
+
),
|
|
71
|
+
"DEVELOPER_TOKEN_INVALID": (
|
|
72
|
+
"Your Google Ads developer token is invalid. Update "
|
|
73
|
+
"`ads.developer_token` in `~/.adloop/config.yaml` using the token "
|
|
74
|
+
"from your manager account API Center."
|
|
75
|
+
),
|
|
66
76
|
"EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE": (
|
|
67
77
|
"Fields used in ORDER BY or HAVING must also appear in the SELECT clause. "
|
|
68
78
|
"Add the missing field to your SELECT."
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from adloop.ads.currency import get_currency_code
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
8
10
|
from adloop.config import AdLoopConfig
|
|
9
11
|
|
|
@@ -57,7 +59,8 @@ def get_campaign_performance(
|
|
|
57
59
|
"""
|
|
58
60
|
|
|
59
61
|
rows = execute_query(config, customer_id, query)
|
|
60
|
-
|
|
62
|
+
currency_code = get_currency_code(config, customer_id)
|
|
63
|
+
_enrich_cost_fields(rows, currency_code)
|
|
61
64
|
|
|
62
65
|
return {"campaigns": rows, "total_campaigns": len(rows)}
|
|
63
66
|
|
|
@@ -75,7 +78,7 @@ def get_ad_performance(
|
|
|
75
78
|
date_clause = _date_clause(date_range_start, date_range_end)
|
|
76
79
|
|
|
77
80
|
query = f"""
|
|
78
|
-
SELECT campaign.name, ad_group.name,
|
|
81
|
+
SELECT campaign.name, campaign.id, ad_group.name, ad_group.id,
|
|
79
82
|
ad_group_ad.ad.id, ad_group_ad.ad.type,
|
|
80
83
|
ad_group_ad.ad.responsive_search_ad.headlines,
|
|
81
84
|
ad_group_ad.ad.responsive_search_ad.descriptions,
|
|
@@ -90,7 +93,8 @@ def get_ad_performance(
|
|
|
90
93
|
"""
|
|
91
94
|
|
|
92
95
|
rows = execute_query(config, customer_id, query)
|
|
93
|
-
|
|
96
|
+
currency_code = get_currency_code(config, customer_id)
|
|
97
|
+
_enrich_cost_fields(rows, currency_code)
|
|
94
98
|
|
|
95
99
|
return {"ads": rows, "total_ads": len(rows)}
|
|
96
100
|
|
|
@@ -122,7 +126,8 @@ def get_keyword_performance(
|
|
|
122
126
|
"""
|
|
123
127
|
|
|
124
128
|
rows = execute_query(config, customer_id, query)
|
|
125
|
-
|
|
129
|
+
currency_code = get_currency_code(config, customer_id)
|
|
130
|
+
_enrich_cost_fields(rows, currency_code)
|
|
126
131
|
|
|
127
132
|
return {"keywords": rows, "total_keywords": len(rows)}
|
|
128
133
|
|
|
@@ -176,7 +181,8 @@ def get_search_terms(
|
|
|
176
181
|
"""
|
|
177
182
|
|
|
178
183
|
rows = execute_query(config, customer_id, query)
|
|
179
|
-
|
|
184
|
+
currency_code = get_currency_code(config, customer_id)
|
|
185
|
+
_enrich_cost_fields(rows, currency_code)
|
|
180
186
|
|
|
181
187
|
return {"search_terms": rows, "total_search_terms": len(rows)}
|
|
182
188
|
|
|
@@ -223,7 +229,7 @@ def _date_clause(start: str, end: str) -> str:
|
|
|
223
229
|
return "AND segments.date DURING LAST_30_DAYS"
|
|
224
230
|
|
|
225
231
|
|
|
226
|
-
def _enrich_cost_fields(rows: list[dict]) -> None:
|
|
232
|
+
def _enrich_cost_fields(rows: list[dict], currency_code: str = "EUR") -> None:
|
|
227
233
|
"""Add human-readable cost and CPA fields computed from cost_micros."""
|
|
228
234
|
for row in rows:
|
|
229
235
|
cost_micros = row.get("metrics.cost_micros", 0) or 0
|
|
@@ -235,4 +241,6 @@ def _enrich_cost_fields(rows: list[dict]) -> None:
|
|
|
235
241
|
|
|
236
242
|
avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0
|
|
237
243
|
if avg_cpc_micros:
|
|
238
|
-
row["metrics.
|
|
244
|
+
row["metrics.average_cpc_amount"] = round(avg_cpc_micros / 1_000_000, 2)
|
|
245
|
+
|
|
246
|
+
row["metrics.currency"] = currency_code
|