gadschain 0.1.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.
- gadschain-0.1.0/LICENSE +21 -0
- gadschain-0.1.0/PKG-INFO +141 -0
- gadschain-0.1.0/README.md +110 -0
- gadschain-0.1.0/pyproject.toml +47 -0
- gadschain-0.1.0/setup.cfg +4 -0
- gadschain-0.1.0/src/gadschain/__init__.py +1 -0
- gadschain-0.1.0/src/gadschain/clients/__init__.py +4 -0
- gadschain-0.1.0/src/gadschain/clients/base.py +42 -0
- gadschain-0.1.0/src/gadschain/clients/google_ads.py +302 -0
- gadschain-0.1.0/src/gadschain/core/__init__.py +3 -0
- gadschain-0.1.0/src/gadschain/core/transformer.py +182 -0
- gadschain-0.1.0/src/gadschain/models/__init__.py +23 -0
- gadschain-0.1.0/src/gadschain/models/campaign.py +98 -0
- gadschain-0.1.0/src/gadschain/server.py +211 -0
- gadschain-0.1.0/src/gadschain.egg-info/PKG-INFO +141 -0
- gadschain-0.1.0/src/gadschain.egg-info/SOURCES.txt +21 -0
- gadschain-0.1.0/src/gadschain.egg-info/dependency_links.txt +1 -0
- gadschain-0.1.0/src/gadschain.egg-info/entry_points.txt +2 -0
- gadschain-0.1.0/src/gadschain.egg-info/requires.txt +8 -0
- gadschain-0.1.0/src/gadschain.egg-info/top_level.txt +1 -0
- gadschain-0.1.0/tests/test_implementation.py +297 -0
- gadschain-0.1.0/tests/test_integration.py +40 -0
- gadschain-0.1.0/tests/test_scaffold.py +45 -0
gadschain-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SnipMCP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gadschain-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gadschain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google Ads MCP server — clean, agent-friendly tools over the Google Ads API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/SnipMCP/gadschain
|
|
7
|
+
Project-URL: Repository, https://github.com/SnipMCP/gadschain
|
|
8
|
+
Project-URL: Issues, https://github.com/SnipMCP/gadschain/issues
|
|
9
|
+
Keywords: mcp,google-ads,ads,marketing,ai-agents
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: fastmcp>=0.1.0
|
|
24
|
+
Requires-Dist: google-ads<32.0.0,>=31.0.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# GadsChain
|
|
33
|
+
|
|
34
|
+
The AI layer between your Google Ads account and your marketing decisions.
|
|
35
|
+
|
|
36
|
+
**Battle-tested.** Six tools cover the daily-ops loop — campaign listing, search-term review, budget tuning, pause/enable, and negative-keyword grooming. All responses are strict Pydantic models. No raw protobuf reaches the agent.
|
|
37
|
+
|
|
38
|
+
## ☁️ Moving to production?
|
|
39
|
+
|
|
40
|
+
The open-source server runs locally with your own API keys.
|
|
41
|
+
For hosted infrastructure with multi-account failover, SLA guarantees,
|
|
42
|
+
and webhook alerts — [join the managed cloud waitlist](https://snipmcp.com).
|
|
43
|
+
|
|
44
|
+
## The Problem
|
|
45
|
+
|
|
46
|
+
Raw Google Ads API returns thousands of rows. One bad campaign structure bleeds budget silently. GadsChain reads, sanitizes, and acts on your ad data before waste compounds.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/SnipMCP/gadschain.git
|
|
52
|
+
cd gadschain
|
|
53
|
+
pip install -e ".[dev]"
|
|
54
|
+
cp .env.example .env
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or with Docker:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
docker-compose up --build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
```env
|
|
66
|
+
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token_here
|
|
67
|
+
GOOGLE_ADS_CLIENT_ID=your_oauth_client_id_here
|
|
68
|
+
GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret_here
|
|
69
|
+
GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token_here
|
|
70
|
+
GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # MCC (manager), digits only
|
|
71
|
+
GOOGLE_ADS_CUSTOMER_ID=1234567890 # default operating account
|
|
72
|
+
GOOGLE_ADS_API_VERSION=v24
|
|
73
|
+
LOG_LEVEL=INFO
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
Three example prompts to send to Claude (or any MCP-compatible agent):
|
|
79
|
+
|
|
80
|
+
1. `Use get_campaigns to show me which campaigns are bleeding budget this month`
|
|
81
|
+
2. `Run get_search_terms for the last 30 days and tell me which queries are wasting spend`
|
|
82
|
+
3. `Add "free", "cheap", "jobs" as negative keywords to campaign 12345`
|
|
83
|
+
|
|
84
|
+
### Run it in two terminals
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Tab 1 — start the MCP server
|
|
88
|
+
python -m gadschain.server
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Tab 2 — call a tool from a Python shell or your MCP client
|
|
93
|
+
# Tool signatures:
|
|
94
|
+
# get_campaigns(customer_id=None)
|
|
95
|
+
# get_search_terms(customer_id=None, days=30, campaign_id=None)
|
|
96
|
+
# update_budget(campaign_id, new_budget_dollars, customer_id=None)
|
|
97
|
+
# pause_campaign(campaign_id, customer_id=None)
|
|
98
|
+
# enable_campaign(campaign_id, customer_id=None)
|
|
99
|
+
# add_negative_keywords(campaign_id, keywords, match_type="BROAD", customer_id=None)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## How it works
|
|
103
|
+
|
|
104
|
+
Three layers between raw Google Ads output and your model:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Google Ads API → [Fetch] → [Transform] → [Act] → MCP Tool → AI Agent
|
|
108
|
+
GAQL micros→$ safe
|
|
109
|
+
queries enum→str mutations
|
|
110
|
+
CTR→% shared-budget guard
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Fetch**: Targeted GAQL queries — only the columns the daily-ops loop actually needs. No `SELECT *`, no protobuf pagination footguns.
|
|
114
|
+
- **Transform**: Currency micros divided to dollars, CTR scaled to percent, enums to human strings, every nested attribute lookup tolerates missing fields without crashing.
|
|
115
|
+
- **Act**: Mutations route through guard rails — `REMOVED` blocked on status changes, shared budgets refused (`shared_budget_refused`), match types validated before any mutate call. The agent never gets an exception; it gets a structured `{"error": ..., "message": ...}` it can reason about.
|
|
116
|
+
|
|
117
|
+
### Real numbers from a live Franka Pizzeria account (28-day window)
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
RAW GOOGLE ADS PAYLOAD GADSCHAIN OUTPUT
|
|
121
|
+
─────────────────────────────────────────────────
|
|
122
|
+
Impressions: 3,389 Spend (28d): $51.41
|
|
123
|
+
Clicks: 163 Conversions: 3 ($17.14 each)
|
|
124
|
+
CTR: 4.81% Conv. rate: 1.84%
|
|
125
|
+
Cost/click: $0.32 avg Surface: Display Network waste
|
|
126
|
+
identified on Fridays
|
|
127
|
+
($0.11 CPC vs $0.44 avg)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In one read of a real account, GadsChain surfaced **$51.41 spent over 28 days for 3 conversions at $17.14 each** — a 1.84% conversion rate hidden inside a 4.81% CTR that looks healthy on paper. The Display Network was the silent culprit, with Friday clicks averaging **$0.11 CPC vs the $0.44 search-side average** — cheap junk traffic inflating CTR while contributing nothing to conversions. The agent saw it because the transformed payload made channel attribution legible instead of buried in protobuf.
|
|
131
|
+
|
|
132
|
+
## Roadmap
|
|
133
|
+
|
|
134
|
+
- Managed cloud tier (hosted, multi-tenant, webhook alerts)
|
|
135
|
+
- Phase 2: ChatGPT REST shim (FastAPI surface over the same six tools)
|
|
136
|
+
- Bid-strategy tuning tools (target CPA, target ROAS)
|
|
137
|
+
- Anomaly alerts on cost-per-conversion drift
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
PRs welcome. Run `pytest` before submitting.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# GadsChain
|
|
2
|
+
|
|
3
|
+
The AI layer between your Google Ads account and your marketing decisions.
|
|
4
|
+
|
|
5
|
+
**Battle-tested.** Six tools cover the daily-ops loop — campaign listing, search-term review, budget tuning, pause/enable, and negative-keyword grooming. All responses are strict Pydantic models. No raw protobuf reaches the agent.
|
|
6
|
+
|
|
7
|
+
## ☁️ Moving to production?
|
|
8
|
+
|
|
9
|
+
The open-source server runs locally with your own API keys.
|
|
10
|
+
For hosted infrastructure with multi-account failover, SLA guarantees,
|
|
11
|
+
and webhook alerts — [join the managed cloud waitlist](https://snipmcp.com).
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
Raw Google Ads API returns thousands of rows. One bad campaign structure bleeds budget silently. GadsChain reads, sanitizes, and acts on your ad data before waste compounds.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/SnipMCP/gadschain.git
|
|
21
|
+
cd gadschain
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
cp .env.example .env
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with Docker:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
docker-compose up --build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
```env
|
|
35
|
+
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token_here
|
|
36
|
+
GOOGLE_ADS_CLIENT_ID=your_oauth_client_id_here
|
|
37
|
+
GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret_here
|
|
38
|
+
GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token_here
|
|
39
|
+
GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # MCC (manager), digits only
|
|
40
|
+
GOOGLE_ADS_CUSTOMER_ID=1234567890 # default operating account
|
|
41
|
+
GOOGLE_ADS_API_VERSION=v24
|
|
42
|
+
LOG_LEVEL=INFO
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Three example prompts to send to Claude (or any MCP-compatible agent):
|
|
48
|
+
|
|
49
|
+
1. `Use get_campaigns to show me which campaigns are bleeding budget this month`
|
|
50
|
+
2. `Run get_search_terms for the last 30 days and tell me which queries are wasting spend`
|
|
51
|
+
3. `Add "free", "cheap", "jobs" as negative keywords to campaign 12345`
|
|
52
|
+
|
|
53
|
+
### Run it in two terminals
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Tab 1 — start the MCP server
|
|
57
|
+
python -m gadschain.server
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Tab 2 — call a tool from a Python shell or your MCP client
|
|
62
|
+
# Tool signatures:
|
|
63
|
+
# get_campaigns(customer_id=None)
|
|
64
|
+
# get_search_terms(customer_id=None, days=30, campaign_id=None)
|
|
65
|
+
# update_budget(campaign_id, new_budget_dollars, customer_id=None)
|
|
66
|
+
# pause_campaign(campaign_id, customer_id=None)
|
|
67
|
+
# enable_campaign(campaign_id, customer_id=None)
|
|
68
|
+
# add_negative_keywords(campaign_id, keywords, match_type="BROAD", customer_id=None)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
Three layers between raw Google Ads output and your model:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Google Ads API → [Fetch] → [Transform] → [Act] → MCP Tool → AI Agent
|
|
77
|
+
GAQL micros→$ safe
|
|
78
|
+
queries enum→str mutations
|
|
79
|
+
CTR→% shared-budget guard
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Fetch**: Targeted GAQL queries — only the columns the daily-ops loop actually needs. No `SELECT *`, no protobuf pagination footguns.
|
|
83
|
+
- **Transform**: Currency micros divided to dollars, CTR scaled to percent, enums to human strings, every nested attribute lookup tolerates missing fields without crashing.
|
|
84
|
+
- **Act**: Mutations route through guard rails — `REMOVED` blocked on status changes, shared budgets refused (`shared_budget_refused`), match types validated before any mutate call. The agent never gets an exception; it gets a structured `{"error": ..., "message": ...}` it can reason about.
|
|
85
|
+
|
|
86
|
+
### Real numbers from a live Franka Pizzeria account (28-day window)
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
RAW GOOGLE ADS PAYLOAD GADSCHAIN OUTPUT
|
|
90
|
+
─────────────────────────────────────────────────
|
|
91
|
+
Impressions: 3,389 Spend (28d): $51.41
|
|
92
|
+
Clicks: 163 Conversions: 3 ($17.14 each)
|
|
93
|
+
CTR: 4.81% Conv. rate: 1.84%
|
|
94
|
+
Cost/click: $0.32 avg Surface: Display Network waste
|
|
95
|
+
identified on Fridays
|
|
96
|
+
($0.11 CPC vs $0.44 avg)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In one read of a real account, GadsChain surfaced **$51.41 spent over 28 days for 3 conversions at $17.14 each** — a 1.84% conversion rate hidden inside a 4.81% CTR that looks healthy on paper. The Display Network was the silent culprit, with Friday clicks averaging **$0.11 CPC vs the $0.44 search-side average** — cheap junk traffic inflating CTR while contributing nothing to conversions. The agent saw it because the transformed payload made channel attribution legible instead of buried in protobuf.
|
|
100
|
+
|
|
101
|
+
## Roadmap
|
|
102
|
+
|
|
103
|
+
- Managed cloud tier (hosted, multi-tenant, webhook alerts)
|
|
104
|
+
- Phase 2: ChatGPT REST shim (FastAPI surface over the same six tools)
|
|
105
|
+
- Bid-strategy tuning tools (target CPA, target ROAS)
|
|
106
|
+
- Anomaly alerts on cost-per-conversion drift
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
PRs welcome. Run `pytest` before submitting.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gadschain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Google Ads MCP server — clean, agent-friendly tools over the Google Ads API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["mcp", "google-ads", "ads", "marketing", "ai-agents"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Office/Business",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"fastmcp>=0.1.0",
|
|
27
|
+
"google-ads>=31.0.0,<32.0.0",
|
|
28
|
+
"pydantic>=2.0.0",
|
|
29
|
+
"python-dotenv>=1.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0.0",
|
|
35
|
+
"pytest-asyncio>=0.23.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
gadschain = "gadschain.server:main"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/SnipMCP/gadschain"
|
|
43
|
+
Repository = "https://github.com/SnipMCP/gadschain"
|
|
44
|
+
Issues = "https://github.com/SnipMCP/gadschain/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AdsClientError(Exception):
|
|
6
|
+
def __init__(self, message: str, code: Optional[str] = None):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.code = code
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseAdsClient(ABC):
|
|
12
|
+
name: str = "base"
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def list_campaigns(self, customer_id: str) -> list[dict]:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def list_search_terms(self, customer_id: str, days: int, campaign_id: Optional[str] = None) -> list[dict]:
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def get_campaign_budget(self, customer_id: str, campaign_id: str) -> dict:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def update_campaign_budget(self, customer_id: str, campaign_id: str, daily_budget_usd: float) -> dict:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def set_campaign_status(self, customer_id: str, campaign_id: str, status: str) -> dict:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def add_negative_keywords(
|
|
36
|
+
self,
|
|
37
|
+
customer_id: str,
|
|
38
|
+
campaign_id: str,
|
|
39
|
+
keywords: list[str],
|
|
40
|
+
match_type: str = "BROAD",
|
|
41
|
+
) -> dict:
|
|
42
|
+
...
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import date, timedelta
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseAdsClient, AdsClientError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
|
|
9
|
+
MUTABLE_STATUSES = {"ENABLED", "PAUSED"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _clean_customer_id(cid: str) -> str:
|
|
13
|
+
"""Strip dashes and whitespace from a Google Ads customer ID."""
|
|
14
|
+
return (cid or "").replace("-", "").replace(" ", "")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _date_range(days: int) -> tuple[str, str]:
|
|
18
|
+
end = date.today()
|
|
19
|
+
start = end - timedelta(days=max(1, int(days)))
|
|
20
|
+
return start.isoformat(), end.isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GoogleAdsClientWrapper(BaseAdsClient):
|
|
24
|
+
"""Wraps google.ads.googleads.client.GoogleAdsClient with lazy auth."""
|
|
25
|
+
|
|
26
|
+
name = "google_ads"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
developer_token: Optional[str] = None,
|
|
31
|
+
client_id: Optional[str] = None,
|
|
32
|
+
client_secret: Optional[str] = None,
|
|
33
|
+
refresh_token: Optional[str] = None,
|
|
34
|
+
login_customer_id: Optional[str] = None,
|
|
35
|
+
api_version: Optional[str] = None,
|
|
36
|
+
):
|
|
37
|
+
self.developer_token = developer_token or os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", "")
|
|
38
|
+
self.client_id = client_id or os.getenv("GOOGLE_ADS_CLIENT_ID", "")
|
|
39
|
+
self.client_secret = client_secret or os.getenv("GOOGLE_ADS_CLIENT_SECRET", "")
|
|
40
|
+
self.refresh_token = refresh_token or os.getenv("GOOGLE_ADS_REFRESH_TOKEN", "")
|
|
41
|
+
self.login_customer_id = _clean_customer_id(
|
|
42
|
+
login_customer_id or os.getenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "")
|
|
43
|
+
)
|
|
44
|
+
self.api_version = api_version or os.getenv("GOOGLE_ADS_API_VERSION") or None
|
|
45
|
+
self._client = None
|
|
46
|
+
|
|
47
|
+
# ── Auth ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def _ensure_client(self):
|
|
50
|
+
if self._client is not None:
|
|
51
|
+
return self._client
|
|
52
|
+
|
|
53
|
+
missing = [
|
|
54
|
+
name for name, val in [
|
|
55
|
+
("GOOGLE_ADS_DEVELOPER_TOKEN", self.developer_token),
|
|
56
|
+
("GOOGLE_ADS_CLIENT_ID", self.client_id),
|
|
57
|
+
("GOOGLE_ADS_CLIENT_SECRET", self.client_secret),
|
|
58
|
+
("GOOGLE_ADS_REFRESH_TOKEN", self.refresh_token),
|
|
59
|
+
] if not val
|
|
60
|
+
]
|
|
61
|
+
if missing:
|
|
62
|
+
raise AdsClientError(
|
|
63
|
+
f"Google Ads credentials missing: {', '.join(missing)}",
|
|
64
|
+
code="missing_credentials",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
69
|
+
except ImportError as e:
|
|
70
|
+
raise AdsClientError(
|
|
71
|
+
f"google-ads library not installed: {e}",
|
|
72
|
+
code="missing_dependency",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
creds = {
|
|
76
|
+
"developer_token": self.developer_token,
|
|
77
|
+
"client_id": self.client_id,
|
|
78
|
+
"client_secret": self.client_secret,
|
|
79
|
+
"refresh_token": self.refresh_token,
|
|
80
|
+
"use_proto_plus": True,
|
|
81
|
+
}
|
|
82
|
+
if self.login_customer_id:
|
|
83
|
+
creds["login_customer_id"] = self.login_customer_id
|
|
84
|
+
|
|
85
|
+
if self.api_version:
|
|
86
|
+
self._client = GoogleAdsClient.load_from_dict(creds, version=self.api_version)
|
|
87
|
+
else:
|
|
88
|
+
self._client = GoogleAdsClient.load_from_dict(creds)
|
|
89
|
+
return self._client
|
|
90
|
+
|
|
91
|
+
# ── Read paths ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def list_campaigns(self, customer_id: str) -> list[dict]:
|
|
94
|
+
client = self._ensure_client()
|
|
95
|
+
cid = _clean_customer_id(customer_id)
|
|
96
|
+
ga_service = client.get_service("GoogleAdsService")
|
|
97
|
+
query = """
|
|
98
|
+
SELECT
|
|
99
|
+
campaign.id,
|
|
100
|
+
campaign.name,
|
|
101
|
+
campaign.status,
|
|
102
|
+
campaign.advertising_channel_type,
|
|
103
|
+
campaign.bidding_strategy_type,
|
|
104
|
+
campaign.start_date,
|
|
105
|
+
campaign.end_date,
|
|
106
|
+
campaign_budget.amount_micros,
|
|
107
|
+
metrics.clicks,
|
|
108
|
+
metrics.impressions,
|
|
109
|
+
metrics.ctr,
|
|
110
|
+
metrics.average_cpc,
|
|
111
|
+
metrics.cost_micros,
|
|
112
|
+
metrics.conversions,
|
|
113
|
+
metrics.cost_per_conversion
|
|
114
|
+
FROM campaign
|
|
115
|
+
WHERE campaign.status != 'REMOVED'
|
|
116
|
+
ORDER BY metrics.cost_micros DESC
|
|
117
|
+
"""
|
|
118
|
+
stream = ga_service.search(customer_id=cid, query=query)
|
|
119
|
+
return list(stream)
|
|
120
|
+
|
|
121
|
+
def list_search_terms(
|
|
122
|
+
self,
|
|
123
|
+
customer_id: str,
|
|
124
|
+
days: int = 30,
|
|
125
|
+
campaign_id: Optional[str] = None,
|
|
126
|
+
) -> list[dict]:
|
|
127
|
+
client = self._ensure_client()
|
|
128
|
+
cid = _clean_customer_id(customer_id)
|
|
129
|
+
ga_service = client.get_service("GoogleAdsService")
|
|
130
|
+
start, end = _date_range(days)
|
|
131
|
+
|
|
132
|
+
where_clauses = [
|
|
133
|
+
f"segments.date BETWEEN '{start}' AND '{end}'",
|
|
134
|
+
"metrics.impressions > 0",
|
|
135
|
+
]
|
|
136
|
+
if campaign_id:
|
|
137
|
+
where_clauses.append(f"campaign.id = {int(campaign_id)}")
|
|
138
|
+
|
|
139
|
+
query = f"""
|
|
140
|
+
SELECT
|
|
141
|
+
search_term_view.search_term,
|
|
142
|
+
metrics.clicks,
|
|
143
|
+
metrics.impressions,
|
|
144
|
+
metrics.ctr,
|
|
145
|
+
metrics.average_cpc,
|
|
146
|
+
metrics.cost_micros,
|
|
147
|
+
metrics.conversions,
|
|
148
|
+
campaign.id,
|
|
149
|
+
campaign.name,
|
|
150
|
+
ad_group.id
|
|
151
|
+
FROM search_term_view
|
|
152
|
+
WHERE {' AND '.join(where_clauses)}
|
|
153
|
+
ORDER BY metrics.cost_micros DESC
|
|
154
|
+
LIMIT 50
|
|
155
|
+
"""
|
|
156
|
+
stream = ga_service.search(customer_id=cid, query=query)
|
|
157
|
+
return list(stream)
|
|
158
|
+
|
|
159
|
+
def get_campaign_budget(self, customer_id: str, campaign_id: str) -> dict:
|
|
160
|
+
client = self._ensure_client()
|
|
161
|
+
cid = _clean_customer_id(customer_id)
|
|
162
|
+
ga_service = client.get_service("GoogleAdsService")
|
|
163
|
+
# The campaign resource carries campaign_budget as an implicit join target;
|
|
164
|
+
# selecting campaign_budget.* from campaign yields the linked budget row.
|
|
165
|
+
# TODO: confirm against live API; fallback is to query `campaign_budget` directly
|
|
166
|
+
# using the resource_name returned by `campaign.campaign_budget`.
|
|
167
|
+
query = f"""
|
|
168
|
+
SELECT
|
|
169
|
+
campaign_budget.id,
|
|
170
|
+
campaign_budget.amount_micros,
|
|
171
|
+
campaign_budget.explicitly_shared
|
|
172
|
+
FROM campaign
|
|
173
|
+
WHERE campaign.id = {int(campaign_id)}
|
|
174
|
+
"""
|
|
175
|
+
rows = list(ga_service.search(customer_id=cid, query=query))
|
|
176
|
+
if not rows:
|
|
177
|
+
raise AdsClientError(
|
|
178
|
+
f"No budget found for campaign {campaign_id} on customer {cid}",
|
|
179
|
+
code="budget_not_found",
|
|
180
|
+
)
|
|
181
|
+
return rows[0]
|
|
182
|
+
|
|
183
|
+
# ── Mutate paths ─────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def update_campaign_budget(
|
|
186
|
+
self,
|
|
187
|
+
customer_id: str,
|
|
188
|
+
campaign_id: str,
|
|
189
|
+
daily_budget_usd: float,
|
|
190
|
+
) -> dict:
|
|
191
|
+
client = self._ensure_client()
|
|
192
|
+
cid = _clean_customer_id(customer_id)
|
|
193
|
+
|
|
194
|
+
existing = self.get_campaign_budget(cid, campaign_id)
|
|
195
|
+
budget_id = existing.campaign_budget.id
|
|
196
|
+
previous_micros = existing.campaign_budget.amount_micros
|
|
197
|
+
explicitly_shared = bool(getattr(existing.campaign_budget, "explicitly_shared", False))
|
|
198
|
+
|
|
199
|
+
if explicitly_shared:
|
|
200
|
+
raise AdsClientError(
|
|
201
|
+
f"Budget {budget_id} is explicitly shared across campaigns. Refusing to mutate.",
|
|
202
|
+
code="shared_budget_refused",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
new_micros = int(round(float(daily_budget_usd) * 1_000_000))
|
|
206
|
+
|
|
207
|
+
budget_service = client.get_service("CampaignBudgetService")
|
|
208
|
+
operation = client.get_type("CampaignBudgetOperation")
|
|
209
|
+
operation.update.resource_name = budget_service.campaign_budget_path(cid, budget_id)
|
|
210
|
+
operation.update.amount_micros = new_micros
|
|
211
|
+
|
|
212
|
+
from google.api_core import protobuf_helpers
|
|
213
|
+
operation.update_mask.CopyFrom(
|
|
214
|
+
protobuf_helpers.field_mask(None, operation.update._pb)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
response = budget_service.mutate_campaign_budgets(
|
|
218
|
+
customer_id=cid,
|
|
219
|
+
operations=[operation],
|
|
220
|
+
)
|
|
221
|
+
return {
|
|
222
|
+
"budget_id": str(budget_id),
|
|
223
|
+
"previous_micros": int(previous_micros) if previous_micros is not None else None,
|
|
224
|
+
"new_micros": new_micros,
|
|
225
|
+
"resource_name": response.results[0].resource_name if response.results else None,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def set_campaign_status(self, customer_id: str, campaign_id: str, status: str) -> dict:
|
|
229
|
+
status = (status or "").upper()
|
|
230
|
+
if status not in MUTABLE_STATUSES:
|
|
231
|
+
raise AdsClientError(
|
|
232
|
+
f"status must be one of {sorted(MUTABLE_STATUSES)}. Got '{status}'.",
|
|
233
|
+
code="invalid_status",
|
|
234
|
+
)
|
|
235
|
+
client = self._ensure_client()
|
|
236
|
+
cid = _clean_customer_id(customer_id)
|
|
237
|
+
campaign_service = client.get_service("CampaignService")
|
|
238
|
+
status_enum = client.enums.CampaignStatusEnum.CampaignStatus
|
|
239
|
+
|
|
240
|
+
operation = client.get_type("CampaignOperation")
|
|
241
|
+
operation.update.resource_name = campaign_service.campaign_path(cid, campaign_id)
|
|
242
|
+
operation.update.status = getattr(status_enum, status)
|
|
243
|
+
|
|
244
|
+
from google.api_core import protobuf_helpers
|
|
245
|
+
operation.update_mask.CopyFrom(
|
|
246
|
+
protobuf_helpers.field_mask(None, operation.update._pb)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
response = campaign_service.mutate_campaigns(
|
|
250
|
+
customer_id=cid,
|
|
251
|
+
operations=[operation],
|
|
252
|
+
)
|
|
253
|
+
return {
|
|
254
|
+
"campaign_id": str(campaign_id),
|
|
255
|
+
"new_status": status,
|
|
256
|
+
"resource_name": response.results[0].resource_name if response.results else None,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def add_negative_keywords(
|
|
260
|
+
self,
|
|
261
|
+
customer_id: str,
|
|
262
|
+
campaign_id: str,
|
|
263
|
+
keywords: list[str],
|
|
264
|
+
match_type: str = "BROAD",
|
|
265
|
+
) -> dict:
|
|
266
|
+
match_type = (match_type or "BROAD").upper()
|
|
267
|
+
if match_type not in VALID_MATCH_TYPES:
|
|
268
|
+
raise AdsClientError(
|
|
269
|
+
f"match_type must be one of {sorted(VALID_MATCH_TYPES)}. Got '{match_type}'.",
|
|
270
|
+
code="invalid_match_type",
|
|
271
|
+
)
|
|
272
|
+
keywords = [k for k in (keywords or []) if k and k.strip()]
|
|
273
|
+
if not keywords:
|
|
274
|
+
raise AdsClientError("keywords list is empty", code="empty_keywords")
|
|
275
|
+
|
|
276
|
+
client = self._ensure_client()
|
|
277
|
+
cid = _clean_customer_id(customer_id)
|
|
278
|
+
campaign_service = client.get_service("CampaignService")
|
|
279
|
+
criterion_service = client.get_service("CampaignCriterionService")
|
|
280
|
+
match_enum = client.enums.KeywordMatchTypeEnum.KeywordMatchType
|
|
281
|
+
|
|
282
|
+
operations = []
|
|
283
|
+
campaign_resource = campaign_service.campaign_path(cid, campaign_id)
|
|
284
|
+
for kw in keywords:
|
|
285
|
+
op = client.get_type("CampaignCriterionOperation")
|
|
286
|
+
op.create.campaign = campaign_resource
|
|
287
|
+
op.create.negative = True
|
|
288
|
+
op.create.keyword.text = kw.strip()
|
|
289
|
+
op.create.keyword.match_type = getattr(match_enum, match_type)
|
|
290
|
+
operations.append(op)
|
|
291
|
+
|
|
292
|
+
response = criterion_service.mutate_campaign_criteria(
|
|
293
|
+
customer_id=cid,
|
|
294
|
+
operations=operations,
|
|
295
|
+
)
|
|
296
|
+
return {
|
|
297
|
+
"campaign_id": str(campaign_id),
|
|
298
|
+
"match_type": match_type,
|
|
299
|
+
"keywords_added": keywords,
|
|
300
|
+
"count": len(response.results),
|
|
301
|
+
"resource_names": [r.resource_name for r in response.results],
|
|
302
|
+
}
|