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.
Files changed (40) hide show
  1. {adloop-0.7.0 → adloop-0.8.0}/PKG-INFO +1 -1
  2. {adloop-0.7.0 → adloop-0.8.0}/pyproject.toml +1 -1
  3. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/__init__.py +1 -1
  4. adloop-0.8.0/src/adloop/ads/enums.py +64 -0
  5. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/forecast.py +139 -44
  6. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/write.py +415 -11
  7. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/cli.py +6 -1
  8. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/adloop.md +5 -1
  9. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/server.py +189 -29
  10. {adloop-0.7.0 → adloop-0.8.0}/README.md +0 -0
  11. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/_mcp_patches.py +0 -0
  13. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/client.py +0 -0
  15. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/currency.py +0 -0
  16. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/gaql.py +0 -0
  17. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/pmax.py +0 -0
  18. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ads/read.py +0 -0
  19. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/auth.py +0 -0
  20. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/bundled_credentials.json +0 -0
  21. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/config.py +0 -0
  22. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/crossref.py +0 -0
  23. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/diagnostics.py +0 -0
  24. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/__init__.py +0 -0
  25. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/client.py +0 -0
  26. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/reports.py +0 -0
  27. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/ga4/tracking.py +0 -0
  28. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/__init__.py +0 -0
  29. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  30. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  31. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/create-ad.md +0 -0
  32. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  33. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  34. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  35. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/rules_install.py +0 -0
  36. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/__init__.py +0 -0
  37. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/audit.py +0 -0
  38. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/guards.py +0 -0
  39. {adloop-0.7.0 → adloop-0.8.0}/src/adloop/safety/preview.py +0 -0
  40. {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.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.7.0"
5
+ __version__ = "0.8.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -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, get_ads_client, normalize_customer_id
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
- googleads_service = client.get_service("GoogleAdsService")
197
- kp_service = client.get_service("KeywordPlanIdeaService")
198
-
199
- request = client.get_type("GenerateKeywordIdeasRequest")
200
- request.customer_id = cid
201
- request.language = googleads_service.language_constant_path(language_id)
202
- request.geo_target_constants.append(
203
- googleads_service.geo_target_constant_path(geo_target_id)
204
- )
205
- request.keyword_plan_network = (
206
- client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
207
- )
208
- request.page_size = min(max(1, page_size), 1000)
209
-
210
- if seed_keywords and url:
211
- request.keyword_and_url_seed.url = url
212
- request.keyword_and_url_seed.keywords.extend(seed_keywords)
213
- elif seed_keywords:
214
- request.keyword_seed.keywords.extend(seed_keywords)
215
- else:
216
- request.url_seed.url = url
217
-
218
- response = call_with_retry(kp_service.generate_keyword_ideas, request=request)
219
-
220
- ideas = []
221
- for idea in response:
222
- metrics = idea.keyword_idea_metrics
223
- avg_monthly = getattr(metrics, "avg_monthly_searches", None)
224
- competition_value = getattr(metrics, "competition", 0)
225
- competition_label = _COMPETITION_LABELS.get(int(competition_value), "UNSPECIFIED")
226
- competition_index = getattr(metrics, "competition_index", None)
227
- low_bid_micros = getattr(metrics, "low_top_of_page_bid_micros", None)
228
- high_bid_micros = getattr(metrics, "high_top_of_page_bid_micros", None)
229
-
230
- ideas.append({
231
- "keyword": idea.text,
232
- "avg_monthly_searches": int(avg_monthly) if avg_monthly else None,
233
- "competition": competition_label,
234
- "competition_index": int(competition_index) if competition_index else None,
235
- "low_top_of_page_bid": round(low_bid_micros / 1_000_000, 2) if low_bid_micros else None,
236
- "high_top_of_page_bid": round(high_bid_micros / 1_000_000, 2) if high_bid_micros else None,
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)