adloop 0.7.0__tar.gz → 0.8.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.7.0 → adloop-0.8.0}/PKG-INFO +1 -1
- {adloop-0.7.0 → adloop-0.8.0}/pyproject.toml +1 -1
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/__init__.py +1 -1
- adloop-0.8.0/src/adloop/ads/enums.py +64 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/forecast.py +139 -44
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/write.py +415 -11
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/cli.py +6 -1
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/adloop.md +5 -1
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/server.py +189 -29
- {adloop-0.7.0 → adloop-0.8.0}/README.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/__main__.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/auth.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/config.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/crossref.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.7.0 → adloop-0.8.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Google Ads enum introspection — pulls valid enum names from the SDK.
|
|
2
|
+
|
|
3
|
+
Avoids hardcoded enum sets in validators. The google-ads SDK ships the
|
|
4
|
+
canonical list for every enum at the API version we're pinned to; this
|
|
5
|
+
module surfaces those lists directly so AdLoop validation stays in sync
|
|
6
|
+
with whatever the SDK supports without us maintaining a parallel copy.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from adloop.ads.enums import enum_names
|
|
10
|
+
_VALID_TYPES = enum_names("ConversionActionTypeEnum")
|
|
11
|
+
|
|
12
|
+
The result is a frozenset of member name strings (e.g. {"AD_CALL", ...}),
|
|
13
|
+
with sentinel values UNSPECIFIED + UNKNOWN dropped by default.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@functools.lru_cache(maxsize=None)
|
|
21
|
+
def _enum_introspection_client():
|
|
22
|
+
"""Memoized no-auth GoogleAdsClient used purely for enum introspection.
|
|
23
|
+
|
|
24
|
+
The client constructor doesn't make any network calls and doesn't
|
|
25
|
+
validate credentials beyond requiring SOMETHING in the developer-token
|
|
26
|
+
field — perfect for reading the bundled enum protos. We cache it so
|
|
27
|
+
every enum_names() call after the first is essentially free.
|
|
28
|
+
"""
|
|
29
|
+
from google.ads.googleads.client import GoogleAdsClient
|
|
30
|
+
|
|
31
|
+
from adloop.ads.client import GOOGLE_ADS_API_VERSION
|
|
32
|
+
|
|
33
|
+
return GoogleAdsClient(
|
|
34
|
+
credentials=None,
|
|
35
|
+
developer_token="adloop-enum-introspection-not-used",
|
|
36
|
+
use_proto_plus=True,
|
|
37
|
+
version=GOOGLE_ADS_API_VERSION,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@functools.lru_cache(maxsize=None)
|
|
42
|
+
def enum_names(
|
|
43
|
+
enum_attr: str, *, exclude_unspecified: bool = True
|
|
44
|
+
) -> frozenset[str]:
|
|
45
|
+
"""Return all member names of a Google Ads enum, as a frozenset.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
enum_attr: the attribute on ``client.enums``, e.g.
|
|
49
|
+
``"ConversionActionTypeEnum"`` or ``"AssetFieldTypeEnum"``.
|
|
50
|
+
exclude_unspecified: when True (default) drops the sentinel values
|
|
51
|
+
``UNSPECIFIED`` and ``UNKNOWN`` — those are protobuf defaults,
|
|
52
|
+
never valid for user input.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AttributeError: if ``enum_attr`` doesn't exist on ``client.enums``.
|
|
56
|
+
|
|
57
|
+
Caching: the result is memoized for the lifetime of the process, so
|
|
58
|
+
repeated calls return the same frozenset instance.
|
|
59
|
+
"""
|
|
60
|
+
enum_cls = getattr(_enum_introspection_client().enums, enum_attr)
|
|
61
|
+
return frozenset(
|
|
62
|
+
m.name for m in enum_cls
|
|
63
|
+
if not exclude_unspecified or m.name not in ("UNSPECIFIED", "UNKNOWN")
|
|
64
|
+
)
|
|
@@ -159,6 +159,90 @@ def estimate_budget(
|
|
|
159
159
|
_COMPETITION_LABELS = {0: "UNSPECIFIED", 1: "LOW", 2: "MEDIUM", 3: "HIGH"}
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
_KEYWORD_IDEAS_REST_URL = (
|
|
163
|
+
"https://googleads.googleapis.com/{version}/customers/{cid}:generateKeywordIdeas"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_keyword_ideas_rest_body(
|
|
168
|
+
*,
|
|
169
|
+
language_id: str,
|
|
170
|
+
geo_target_id: str,
|
|
171
|
+
page_size: int,
|
|
172
|
+
seed_keywords: list[str],
|
|
173
|
+
url: str,
|
|
174
|
+
page_token: str = "",
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Build the JSON body for the REST generateKeywordIdeas endpoint.
|
|
177
|
+
|
|
178
|
+
Schema follows google-ads REST v23 (camelCase). Exactly one of
|
|
179
|
+
``keywordSeed`` / ``urlSeed`` / ``keywordAndUrlSeed`` is set based on
|
|
180
|
+
which inputs were provided.
|
|
181
|
+
"""
|
|
182
|
+
body: dict = {
|
|
183
|
+
"language": f"languageConstants/{language_id}",
|
|
184
|
+
"geoTargetConstants": [f"geoTargetConstants/{geo_target_id}"],
|
|
185
|
+
"keywordPlanNetwork": "GOOGLE_SEARCH",
|
|
186
|
+
"pageSize": page_size,
|
|
187
|
+
}
|
|
188
|
+
if seed_keywords and url:
|
|
189
|
+
body["keywordAndUrlSeed"] = {"url": url, "keywords": list(seed_keywords)}
|
|
190
|
+
elif seed_keywords:
|
|
191
|
+
body["keywordSeed"] = {"keywords": list(seed_keywords)}
|
|
192
|
+
else:
|
|
193
|
+
body["urlSeed"] = {"url": url}
|
|
194
|
+
if page_token:
|
|
195
|
+
body["pageToken"] = page_token
|
|
196
|
+
return body
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _post_keyword_ideas_rest_page(
|
|
200
|
+
config: AdLoopConfig, cid: str, body: dict
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""POST a single page request to the REST generateKeywordIdeas endpoint.
|
|
203
|
+
|
|
204
|
+
Issue #37: ``KeywordPlanIdeaService.GenerateKeywordIdeas`` over gRPC sits
|
|
205
|
+
in a tight quota bucket that exhausts after a small number of sequential
|
|
206
|
+
calls and returns ``RESOURCE_EXHAUSTED`` regardless of QPS. The REST v23
|
|
207
|
+
endpoint for the same method lives in a separate, much larger quota
|
|
208
|
+
bucket, so this swap eliminates the 429s that made ``discover_keywords``
|
|
209
|
+
unusable for any multi-geo or repeat-call workflow. Filed against
|
|
210
|
+
google-ads-python; see https://github.com/kLOsk/adloop/issues/37.
|
|
211
|
+
|
|
212
|
+
Re-raises HTTP 429s as a string-formatted error so the existing
|
|
213
|
+
``call_with_retry`` helper can apply exponential backoff and re-attempt.
|
|
214
|
+
"""
|
|
215
|
+
import requests
|
|
216
|
+
from google.auth.transport.requests import AuthorizedSession
|
|
217
|
+
|
|
218
|
+
from adloop.ads.client import GOOGLE_ADS_API_VERSION
|
|
219
|
+
from adloop.auth import get_ads_credentials
|
|
220
|
+
|
|
221
|
+
credentials = get_ads_credentials(config)
|
|
222
|
+
session = AuthorizedSession(credentials)
|
|
223
|
+
|
|
224
|
+
headers = {
|
|
225
|
+
"developer-token": config.ads.developer_token,
|
|
226
|
+
"Content-Type": "application/json",
|
|
227
|
+
}
|
|
228
|
+
if config.ads.login_customer_id:
|
|
229
|
+
headers["login-customer-id"] = config.ads.login_customer_id.replace("-", "")
|
|
230
|
+
|
|
231
|
+
url = _KEYWORD_IDEAS_REST_URL.format(version=GOOGLE_ADS_API_VERSION, cid=cid)
|
|
232
|
+
response = session.post(url, json=body, headers=headers, timeout=60)
|
|
233
|
+
|
|
234
|
+
if response.status_code == 429:
|
|
235
|
+
# Surface as a RESOURCE_EXHAUSTED string so call_with_retry recognises
|
|
236
|
+
# this as a rate-limit error and backs off. The REST bucket is much
|
|
237
|
+
# larger than gRPC so this branch should be rare, but handle it.
|
|
238
|
+
raise requests.HTTPError(
|
|
239
|
+
f"RESOURCE_EXHAUSTED (HTTP 429) from REST generateKeywordIdeas: "
|
|
240
|
+
f"{response.text[:500]}"
|
|
241
|
+
)
|
|
242
|
+
response.raise_for_status()
|
|
243
|
+
return response.json()
|
|
244
|
+
|
|
245
|
+
|
|
162
246
|
def discover_keywords(
|
|
163
247
|
config: AdLoopConfig,
|
|
164
248
|
*,
|
|
@@ -184,57 +268,68 @@ def discover_keywords(
|
|
|
184
268
|
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
|
|
185
269
|
language_id: language constant (1000=English, 1001=German, 1002=French)
|
|
186
270
|
page_size: max number of keyword ideas to return (default 50, max 1000)
|
|
271
|
+
|
|
272
|
+
Network: this tool intentionally bypasses the google-ads gRPC client for
|
|
273
|
+
KeywordPlanIdeaService and calls the v23 REST endpoint directly. The
|
|
274
|
+
gRPC quota bucket for this single method exhausts almost immediately
|
|
275
|
+
under sequential single-geo calls (issue #37); REST sits in a separate,
|
|
276
|
+
much larger bucket and works without issue. All other Ads tools still
|
|
277
|
+
use the gRPC client.
|
|
187
278
|
"""
|
|
188
|
-
from adloop.ads.client import call_with_retry,
|
|
279
|
+
from adloop.ads.client import call_with_retry, normalize_customer_id
|
|
189
280
|
|
|
190
281
|
seed_keywords = list(seed_keywords)
|
|
191
282
|
if not seed_keywords and not url:
|
|
192
283
|
return {"error": "Provide at least one of: seed_keywords or url"}
|
|
193
284
|
|
|
194
|
-
client = get_ads_client(config)
|
|
195
285
|
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
capped_page_size = min(max(1, page_size), 1000)
|
|
287
|
+
|
|
288
|
+
ideas: list[dict] = []
|
|
289
|
+
page_token = ""
|
|
290
|
+
while True:
|
|
291
|
+
body = _build_keyword_ideas_rest_body(
|
|
292
|
+
language_id=language_id,
|
|
293
|
+
geo_target_id=geo_target_id,
|
|
294
|
+
page_size=capped_page_size,
|
|
295
|
+
seed_keywords=seed_keywords,
|
|
296
|
+
url=url,
|
|
297
|
+
page_token=page_token,
|
|
298
|
+
)
|
|
299
|
+
payload = call_with_retry(_post_keyword_ideas_rest_page, config, cid, body)
|
|
300
|
+
|
|
301
|
+
for idea in payload.get("results", []):
|
|
302
|
+
metrics = idea.get("keywordIdeaMetrics", {}) or {}
|
|
303
|
+
avg_monthly = metrics.get("avgMonthlySearches")
|
|
304
|
+
competition = metrics.get("competition") or "UNSPECIFIED"
|
|
305
|
+
competition_index = metrics.get("competitionIndex")
|
|
306
|
+
low_bid_micros = metrics.get("lowTopOfPageBidMicros")
|
|
307
|
+
high_bid_micros = metrics.get("highTopOfPageBidMicros")
|
|
308
|
+
|
|
309
|
+
# int64 fields come back as JSON strings in REST — normalize.
|
|
310
|
+
avg_monthly_int = int(avg_monthly) if avg_monthly else None
|
|
311
|
+
competition_index_int = (
|
|
312
|
+
int(competition_index) if competition_index else None
|
|
313
|
+
)
|
|
314
|
+
low_bid_int = int(low_bid_micros) if low_bid_micros else None
|
|
315
|
+
high_bid_int = int(high_bid_micros) if high_bid_micros else None
|
|
316
|
+
|
|
317
|
+
ideas.append({
|
|
318
|
+
"keyword": idea.get("text", ""),
|
|
319
|
+
"avg_monthly_searches": avg_monthly_int,
|
|
320
|
+
"competition": competition,
|
|
321
|
+
"competition_index": competition_index_int,
|
|
322
|
+
"low_top_of_page_bid": (
|
|
323
|
+
round(low_bid_int / 1_000_000, 2) if low_bid_int else None
|
|
324
|
+
),
|
|
325
|
+
"high_top_of_page_bid": (
|
|
326
|
+
round(high_bid_int / 1_000_000, 2) if high_bid_int else None
|
|
327
|
+
),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
page_token = payload.get("nextPageToken") or ""
|
|
331
|
+
if not page_token:
|
|
332
|
+
break
|
|
238
333
|
|
|
239
334
|
# Sort by avg monthly searches descending (None last)
|
|
240
335
|
ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
|