adloop 0.6.5__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 (41) hide show
  1. {adloop-0.6.5 → adloop-0.8.0}/PKG-INFO +17 -2
  2. {adloop-0.6.5 → adloop-0.8.0}/README.md +16 -1
  3. {adloop-0.6.5 → adloop-0.8.0}/pyproject.toml +1 -1
  4. adloop-0.8.0/src/adloop/__init__.py +47 -0
  5. adloop-0.8.0/src/adloop/ads/enums.py +64 -0
  6. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/forecast.py +139 -44
  7. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/write.py +519 -24
  8. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/cli.py +120 -2
  9. adloop-0.8.0/src/adloop/rules/__init__.py +6 -0
  10. adloop-0.8.0/src/adloop/rules/adloop.md +584 -0
  11. adloop-0.8.0/src/adloop/rules/commands/analyze-performance.md +32 -0
  12. adloop-0.8.0/src/adloop/rules/commands/budget-plan.md +34 -0
  13. adloop-0.8.0/src/adloop/rules/commands/create-ad.md +42 -0
  14. adloop-0.8.0/src/adloop/rules/commands/create-campaign.md +46 -0
  15. adloop-0.8.0/src/adloop/rules/commands/diagnose-tracking.md +44 -0
  16. adloop-0.8.0/src/adloop/rules/commands/optimize-campaign.md +52 -0
  17. adloop-0.8.0/src/adloop/rules_install.py +470 -0
  18. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/server.py +204 -31
  19. adloop-0.6.5/src/adloop/__init__.py +0 -29
  20. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/__main__.py +0 -0
  21. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/_mcp_patches.py +0 -0
  22. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/__init__.py +0 -0
  23. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/client.py +0 -0
  24. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/currency.py +0 -0
  25. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/gaql.py +0 -0
  26. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/pmax.py +0 -0
  27. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ads/read.py +0 -0
  28. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/bundled_credentials.json +0 -0
  30. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/config.py +0 -0
  31. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/crossref.py +0 -0
  32. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/diagnostics.py +0 -0
  33. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/__init__.py +0 -0
  34. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/client.py +0 -0
  35. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/reports.py +0 -0
  36. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/ga4/tracking.py +0 -0
  37. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/__init__.py +0 -0
  38. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/audit.py +0 -0
  39. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/guards.py +0 -0
  40. {adloop-0.6.5 → adloop-0.8.0}/src/adloop/safety/preview.py +0 -0
  41. {adloop-0.6.5 → 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.6.5
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
@@ -318,7 +318,22 @@ Or add to your project's `.mcp.json`:
318
318
  }
319
319
  ```
320
320
 
321
- Then copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project for orchestration rules and slash commands.
321
+ Then install the orchestration rules + slash commands globally so every Claude Code session inherits them:
322
+
323
+ ```bash
324
+ adloop install-rules
325
+ ```
326
+
327
+ This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
328
+
329
+ - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
330
+ - **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
331
+
332
+ To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
333
+
334
+ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project's `.claude/` directory.
335
+
336
+ **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.
322
337
 
323
338
  </details>
324
339
 
@@ -294,7 +294,22 @@ Or add to your project's `.mcp.json`:
294
294
  }
295
295
  ```
296
296
 
297
- Then copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project for orchestration rules and slash commands.
297
+ Then install the orchestration rules + slash commands globally so every Claude Code session inherits them:
298
+
299
+ ```bash
300
+ adloop install-rules
301
+ ```
302
+
303
+ This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
304
+
305
+ - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
306
+ - **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
307
+
308
+ To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
309
+
310
+ If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project's `.claude/` directory.
311
+
312
+ **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.
298
313
 
299
314
  </details>
300
315
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.6.5"
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 = [
@@ -0,0 +1,47 @@
1
+ """AdLoop — MCP server connecting Google Ads + GA4 + codebase."""
2
+
3
+ import sys
4
+
5
+ __version__ = "0.8.0"
6
+
7
+
8
+ def main() -> None:
9
+ """Entry point for `adloop` console script.
10
+
11
+ Subcommands:
12
+ adloop Start the MCP server (default).
13
+ adloop init Run the interactive setup wizard.
14
+ adloop install-rules Install Claude orchestration rules globally.
15
+ adloop update-rules Refresh the installed rules block.
16
+ adloop uninstall-rules Remove the installed rules block + commands.
17
+ adloop --version, -V Print version and exit.
18
+ """
19
+ args = sys.argv[1:]
20
+
21
+ if args and args[0] in ("--version", "-V"):
22
+ print(f"adloop {__version__}")
23
+ return
24
+
25
+ if args and args[0] == "init":
26
+ from adloop.cli import run_init_wizard
27
+
28
+ try:
29
+ run_init_wizard()
30
+ except KeyboardInterrupt:
31
+ print("\n\n Setup cancelled.\n")
32
+ sys.exit(130)
33
+ return
34
+
35
+ if args and args[0] in ("install-rules", "update-rules", "uninstall-rules"):
36
+ from adloop.cli import run_rules_command
37
+
38
+ try:
39
+ sys.exit(run_rules_command(args[0], args[1:]))
40
+ except KeyboardInterrupt:
41
+ print("\n\n Cancelled.\n")
42
+ sys.exit(130)
43
+ return
44
+
45
+ from adloop.server import mcp
46
+
47
+ mcp.run()
@@ -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)