adloop 0.13.0__tar.gz → 0.13.3__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 (52) hide show
  1. {adloop-0.13.0 → adloop-0.13.3}/PKG-INFO +9 -5
  2. {adloop-0.13.0 → adloop-0.13.3}/README.md +8 -4
  3. adloop-0.13.3/pyproject.toml +50 -0
  4. adloop-0.13.0/pyproject.toml → adloop-0.13.3/pyproject.toml.orig +1 -1
  5. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/__init__.py +21 -2
  6. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/read.py +46 -4
  7. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/write.py +72 -15
  8. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/config.py +32 -12
  9. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/merchant/client.py +36 -9
  10. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/merchant/read.py +6 -1
  11. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/adloop.md +23 -10
  12. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/server.py +103 -67
  13. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/__main__.py +0 -0
  14. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/_mcp_patches.py +0 -0
  15. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/__init__.py +0 -0
  16. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/currency.py +0 -0
  18. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/enums.py +0 -0
  19. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/forecast.py +0 -0
  20. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/gaql.py +0 -0
  21. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ads/pmax.py +0 -0
  22. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/auth.py +0 -0
  23. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/cli.py +0 -0
  24. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/crossref.py +0 -0
  25. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/diagnostics.py +0 -0
  26. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ga4/__init__.py +0 -0
  27. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ga4/client.py +0 -0
  28. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ga4/reports.py +0 -0
  29. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ga4/tracking.py +0 -0
  30. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/ga4/write.py +0 -0
  31. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gsc/__init__.py +0 -0
  32. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gsc/client.py +0 -0
  33. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gsc/reports.py +0 -0
  34. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gtm/__init__.py +0 -0
  35. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gtm/client.py +0 -0
  36. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/gtm/read.py +0 -0
  37. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/merchant/__init__.py +0 -0
  38. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/pagespeed.py +0 -0
  39. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/__init__.py +0 -0
  40. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/analyze-performance.md +0 -0
  41. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/budget-plan.md +0 -0
  42. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/create-ad.md +0 -0
  43. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/create-campaign.md +0 -0
  44. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  45. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  46. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/rules_install.py +0 -0
  47. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/runtime.py +0 -0
  48. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/safety/__init__.py +0 -0
  49. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/safety/audit.py +0 -0
  50. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/safety/guards.py +0 -0
  51. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/safety/preview.py +0 -0
  52. {adloop-0.13.0 → adloop-0.13.3}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.13.0
3
+ Version: 0.13.3
4
4
  Summary: The AI command center for Google Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -24,6 +24,8 @@ Project-URL: Changelog, https://github.com/kLOsk/adloop/releases
24
24
  Provides-Extra: dev
25
25
  Description-Content-Type: text/markdown
26
26
 
27
+ <!-- mcp-name: com.getadloop/adloop -->
28
+
27
29
  <div align="center">
28
30
 
29
31
  # AdLoop
@@ -40,12 +42,14 @@ Description-Content-Type: text/markdown
40
42
 
41
43
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
42
44
 
43
- **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
45
+ **[☁️ Skip the setup — use AdLoop Cloud (free plan, no card)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
44
46
 
45
47
  </div>
46
48
 
47
49
  > [!TIP]
48
- > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
50
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
51
+
52
+ > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
49
53
 
50
54
  ---
51
55
 
@@ -61,7 +65,7 @@ Both versions run the same tools with the same safety model. The difference is w
61
65
  | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
62
66
  | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
63
67
  | **Updates** | Automatic | `pip install -U adloop` |
64
- | **Price** | Free during beta | Free forever (MIT) |
68
+ | **Price** | Free plan, no card; [paid plans](https://getadloop.com/preise) for more accounts and volume | Free forever (MIT) |
65
69
 
66
70
  **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
67
71
 
@@ -515,7 +519,7 @@ What's been shipped and what's next:
515
519
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
516
520
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
517
521
  - ~~PyPI package~~ ✓ — `pip install adloop`
518
- - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
522
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
519
523
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
520
524
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
521
525
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
@@ -1,3 +1,5 @@
1
+ <!-- mcp-name: com.getadloop/adloop -->
2
+
1
3
  <div align="center">
2
4
 
3
5
  # AdLoop
@@ -14,12 +16,14 @@
14
16
 
15
17
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
16
18
 
17
- **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
19
+ **[☁️ Skip the setup — use AdLoop Cloud (free plan, no card)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
18
20
 
19
21
  </div>
20
22
 
21
23
  > [!TIP]
22
- > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
24
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
25
+
26
+ > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
23
27
 
24
28
  ---
25
29
 
@@ -35,7 +39,7 @@ Both versions run the same tools with the same safety model. The difference is w
35
39
  | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
36
40
  | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
37
41
  | **Updates** | Automatic | `pip install -U adloop` |
38
- | **Price** | Free during beta | Free forever (MIT) |
42
+ | **Price** | Free plan, no card; [paid plans](https://getadloop.com/preise) for more accounts and volume | Free forever (MIT) |
39
43
 
40
44
  **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
41
45
 
@@ -489,7 +493,7 @@ What's been shipped and what's next:
489
493
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
490
494
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
491
495
  - ~~PyPI package~~ ✓ — `pip install adloop`
492
- - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
496
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
493
497
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
494
498
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
495
499
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "adloop"
3
+ version = "0.13.3"
4
+ description = "The AI command center for Google Ads, GA4, and tracking code."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ keywords = [
8
+ "mcp",
9
+ "google-ads",
10
+ "google-analytics",
11
+ "ga4",
12
+ "cursor",
13
+ "marketing",
14
+ ]
15
+ dependencies = [
16
+ "fastmcp>=3.0.0",
17
+ "google-ads>=31.1.0",
18
+ "google-analytics-data>=0.20.0",
19
+ "google-analytics-admin>=0.27.0",
20
+ "google-api-python-client>=2.100.0",
21
+ "google-auth-oauthlib>=1.0.0",
22
+ "google-api-python-client>=2.0.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "Daniel Klose"
28
+ email = "info@daniel-klose.com"
29
+
30
+ [project.license]
31
+ text = "MIT"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/kLOsk/adloop"
35
+ Repository = "https://github.com/kLOsk/adloop"
36
+ Issues = "https://github.com/kLOsk/adloop/issues"
37
+ Changelog = "https://github.com/kLOsk/adloop/releases"
38
+
39
+ [project.scripts]
40
+ adloop = "adloop:main"
41
+
42
+ [project.optional-dependencies]
43
+ dev = [
44
+ "pytest>=8.0",
45
+ "pytest-asyncio>=0.23",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.10.8,<0.11.0"]
50
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.13.0"
3
+ version = "0.13.3"
4
4
  description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,6 +9,25 @@ except PackageNotFoundError: # running from a source tree without install
9
9
  __version__ = "0.0.0.dev0"
10
10
 
11
11
 
12
+ def install_runtime_patches() -> None:
13
+ """Arm the upstream-bug workarounds for a long-running host process.
14
+
15
+ Importing ``adloop.server`` is deliberately free of process-global side
16
+ effects so the server can be embedded in an ASGI app. That leaves the
17
+ workarounds in ``_mcp_patches`` unarmed for any embedder, including a
18
+ hosted deployment, which is exactly where they matter most: the
19
+ cancellation race they guard against (python-sdk#2416) fires when a
20
+ host cancels a slow tool call, and it takes the transport down with it.
21
+
22
+ Embedders should call this once during application startup. It is
23
+ idempotent, never raises, and each patch inside self-disarms once the
24
+ upstream bug it tracks is fixed.
25
+ """
26
+ from adloop import _mcp_patches
27
+
28
+ _mcp_patches.install()
29
+
30
+
12
31
  def main() -> None:
13
32
  """Entry point for `adloop` console script.
14
33
 
@@ -50,10 +69,10 @@ def main() -> None:
50
69
  # the stdio cancellation-race monkeypatch) are deliberately installed
51
70
  # here — in the stdio entry point — rather than at adloop.server import
52
71
  # time, so embedding the server in an ASGI app stays side-effect-free.
53
- from adloop import _mcp_patches, diagnostics
72
+ from adloop import diagnostics
54
73
 
55
74
  diagnostics.install()
56
- _mcp_patches.install()
75
+ install_runtime_patches()
57
76
 
58
77
  from adloop.server import mcp
59
78
 
@@ -278,10 +278,24 @@ def get_keyword_performance(
278
278
  return {"keywords": rows, "total_keywords": len(rows)}
279
279
 
280
280
  qs_field = "ad_group_criterion.quality_info.quality_score"
281
+
282
+ def _rated_score(r: dict) -> int | None:
283
+ """The Quality Score, or None when Google has not assigned one.
284
+
285
+ Google never issues a real score of 0: both null and 0 mean "not
286
+ enough impressions to rate". Counting those as "< 5" reports an
287
+ ordinary unrated long tail as an account-wide relevance problem and
288
+ sends the assistant off fixing ads and landing pages that are fine.
289
+ """
290
+ score = r.get(qs_field)
291
+
292
+ return score if isinstance(score, int) and score > 0 else None
293
+
281
294
  low_quality = [
282
295
  r for r in rows
283
- if r.get(qs_field) is not None and (r.get(qs_field) or 0) < 5
296
+ if (score := _rated_score(r)) is not None and score < 5
284
297
  ]
298
+ unrated_count = sum(1 for r in rows if _rated_score(r) is None)
285
299
  zero_conv_spenders = [
286
300
  r for r in rows
287
301
  if (r.get("metrics.cost_micros") or 0) > 0
@@ -304,6 +318,11 @@ def get_keyword_performance(
304
318
  f"{len(low_quality)} keyword(s) have quality score < 5 — fix ad "
305
319
  f"relevance and landing pages before adding keywords or budget."
306
320
  )
321
+ if unrated_count:
322
+ insights.append(
323
+ f"{unrated_count} keyword(s) have no quality score yet (too few "
324
+ f"impressions to rate). They are excluded from the count above."
325
+ )
307
326
  if zero_conv_spenders:
308
327
  wasted = round(
309
328
  sum((r.get("metrics.cost_micros") or 0) for r in zero_conv_spenders)
@@ -325,6 +344,7 @@ def get_keyword_performance(
325
344
  ),
326
345
  "keywords_top_spend": top,
327
346
  "low_quality_score": [_kw(r) for r in low_quality[:10]],
347
+ "unrated_quality_score": unrated_count,
328
348
  "zero_conversion_spenders": [_kw(r) for r in zero_conv_spenders[:10]],
329
349
  "insights": insights,
330
350
  "note": _compact_note(len(top), len(rows), "get_keyword_performance"),
@@ -430,7 +450,7 @@ def get_search_terms(
430
450
  return {
431
451
  "compact": True,
432
452
  "total_search_terms": len(rows),
433
- "totals": _compact_totals(rows, currency_code),
453
+ "totals": _compact_totals(rows, currency_code, row_limit=200),
434
454
  "search_terms_top_clicks": top,
435
455
  "waste_candidates": [_term(r) for r in waste[:10]],
436
456
  "top_converters": [_term(r) for r in converters[:5]],
@@ -923,8 +943,15 @@ def _date_clause(start: str, end: str) -> str:
923
943
  return "AND segments.date DURING LAST_30_DAYS"
924
944
 
925
945
 
926
- def _compact_totals(rows: list[dict], currency_code: str) -> dict:
927
- """Deterministic account-level aggregates over enriched metric rows."""
946
+ def _compact_totals(
947
+ rows: list[dict], currency_code: str, *, row_limit: int | None = None
948
+ ) -> dict:
949
+ """Deterministic aggregates over the rows given.
950
+
951
+ Not account-level whenever the query behind them carries a LIMIT: pass
952
+ ``row_limit`` for those reports so a truncated total is marked partial
953
+ instead of being read as the account's real cost and conversions.
954
+ """
928
955
  cost = sum((r.get("metrics.cost_micros") or 0) for r in rows) / 1_000_000
929
956
  clicks = sum((r.get("metrics.clicks") or 0) for r in rows)
930
957
  impressions = sum((r.get("metrics.impressions") or 0) for r in rows)
@@ -940,6 +967,21 @@ def _compact_totals(rows: list[dict], currency_code: str) -> dict:
940
967
  totals["cpa"] = round(cost / conversions, 2)
941
968
  if impressions > 0:
942
969
  totals["ctr_pct"] = round(clicks / impressions * 100, 2)
970
+
971
+ # Hitting the ceiling exactly is indistinguishable from stopping there, so
972
+ # this errs toward declaring partial. Silently under-reporting cost is the
973
+ # worse failure: these numbers are labelled "totals" and get compared
974
+ # against campaign-level truth.
975
+ if row_limit is not None and len(rows) >= row_limit:
976
+ totals["partial"] = True
977
+ totals["rows_counted"] = len(rows)
978
+ totals["partial_reason"] = (
979
+ f"The underlying query returns at most {row_limit} rows, ranked by "
980
+ f"the report's sort metric, and this account reached that ceiling. "
981
+ f"These totals cover those rows only, not the whole account. Use "
982
+ f"get_campaign_performance for account-level figures."
983
+ )
984
+
943
985
  return totals
944
986
 
945
987
 
@@ -270,17 +270,49 @@ def _build_public_only_opener():
270
270
  return urllib.request.build_opener(_PublicOnlyRedirectHandler)
271
271
 
272
272
 
273
- def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
273
+ # Statuses that say nothing about whether the landing page is any good:
274
+ # the site is throttling us, or is briefly unwell. Refusing to draft an ad
275
+ # over one punishes the advertiser for their own rate limiting — and we
276
+ # are frequently the cause of it, since drafting several ads at once fires
277
+ # several HEAD requests at the same origin within a second or two.
278
+ #
279
+ # A genuinely dead URL still blocks: 404 and 410 are the cases this check
280
+ # exists for, and they do not resolve themselves on a retry.
281
+ _INCONCLUSIVE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
282
+
283
+
284
+ def _validate_urls(
285
+ urls: list[str], timeout: int = 10
286
+ ) -> tuple[dict[str, str | None], dict[str, str]]:
274
287
  """Check that each URL returns a 2xx/3xx status.
275
288
 
276
- Returns a dict of {url: error_message_or_None}. None means the URL is fine.
289
+ Returns (errors, warnings). ``errors`` maps url -> message for URLs
290
+ that should block the operation, None when fine. ``warnings`` maps
291
+ url -> message for checks that came back inconclusive; callers should
292
+ surface those but proceed, because the alternative is refusing to work
293
+ whenever the advertiser's own site is briefly throttling or flaky.
277
294
  """
278
295
  import urllib.request
279
296
  import urllib.error
280
297
 
281
298
  opener = _build_public_only_opener()
282
299
 
283
- results = {}
300
+ results: dict[str, str | None] = {}
301
+ warnings: dict[str, str] = {}
302
+
303
+ def _record_status(url: str, status: int) -> None:
304
+ if status in _INCONCLUSIVE_STATUSES:
305
+ results[url] = None
306
+ warnings[url] = (
307
+ f"HTTP {status} — could not verify the URL right now "
308
+ "(the site may be rate-limiting or temporarily down). "
309
+ "Proceeding without the check; confirm the page is live."
310
+ )
311
+ elif status >= 400:
312
+ results[url] = f"HTTP {status}"
313
+ else:
314
+ results[url] = None
315
+
284
316
  for url in urls:
285
317
  if not url:
286
318
  continue
@@ -292,10 +324,7 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
292
324
  req = urllib.request.Request(url, method="HEAD")
293
325
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
294
326
  resp = opener.open(req, timeout=timeout)
295
- if resp.status >= 400:
296
- results[url] = f"HTTP {resp.status}"
297
- else:
298
- results[url] = None
327
+ _record_status(url, resp.status)
299
328
  except urllib.error.HTTPError as e:
300
329
  if e.code == 405:
301
330
  # HEAD not allowed, try GET
@@ -303,18 +332,17 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]:
303
332
  req = urllib.request.Request(url, method="GET")
304
333
  req.add_header("User-Agent", "AdLoop-URLCheck/1.0")
305
334
  resp = opener.open(req, timeout=timeout)
306
- if resp.status >= 400:
307
- results[url] = f"HTTP {resp.status}"
308
- else:
309
- results[url] = None
335
+ _record_status(url, resp.status)
336
+ except urllib.error.HTTPError as e2:
337
+ _record_status(url, e2.code)
310
338
  except Exception as e2:
311
339
  results[url] = str(e2)
312
340
  else:
313
- results[url] = f"HTTP {e.code}"
341
+ _record_status(url, e.code)
314
342
  except Exception as e:
315
343
  results[url] = str(e)
316
344
 
317
- return results
345
+ return results, warnings
318
346
 
319
347
 
320
348
  def _normalize_display_network_setting(
@@ -467,7 +495,7 @@ def draft_responsive_search_ad(
467
495
  if errors:
468
496
  return {"error": "Validation failed", "details": errors}
469
497
 
470
- url_check = _validate_urls([final_url])
498
+ url_check, url_warnings = _validate_urls([final_url])
471
499
  if url_check.get(final_url):
472
500
  return {
473
501
  "error": "URL validation failed",
@@ -478,6 +506,8 @@ def draft_responsive_search_ad(
478
506
  }
479
507
 
480
508
  warnings = []
509
+ if url_warnings.get(final_url):
510
+ warnings.append(f"final_url '{final_url}': {url_warnings[final_url]}")
481
511
  if len(headlines) < 8:
482
512
  warnings.append(
483
513
  f"Only {len(headlines)} headlines provided. Google recommends 8-15 "
@@ -1703,7 +1733,7 @@ def draft_sitelinks(
1703
1733
  return {"error": "Validation failed", "details": errors}
1704
1734
 
1705
1735
  sitelink_urls = [sl["final_url"] for sl in validated]
1706
- url_checks = _validate_urls(sitelink_urls)
1736
+ url_checks, url_warnings = _validate_urls(sitelink_urls)
1707
1737
  bad_urls = {u: err for u, err in url_checks.items() if err}
1708
1738
  if bad_urls:
1709
1739
  return {
@@ -1712,6 +1742,7 @@ def draft_sitelinks(
1712
1742
  f"'{url}' is not reachable: {err}" for url, err in bad_urls.items()
1713
1743
  ],
1714
1744
  }
1745
+ warnings.extend(f"'{url}': {msg}" for url, msg in url_warnings.items())
1715
1746
 
1716
1747
  if len(validated) < 2:
1717
1748
  warnings.append(
@@ -2291,6 +2322,32 @@ def _validate_campaign(
2291
2322
  f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, "
2292
2323
  f"got '{channel_type}'"
2293
2324
  )
2325
+
2326
+ # Performance Max has no ad groups. Every campaign needs at least one
2327
+ # asset group, and the API requires the asset group plus all of its
2328
+ # required assets in a single atomic mutate. We cannot supply the
2329
+ # images, so creating the campaign alone would leave the user with
2330
+ # something that can never serve while reporting success.
2331
+ if ct == "PERFORMANCE_MAX":
2332
+ errors.append(
2333
+ "Performance Max campaigns cannot be created yet. PMax has no ad "
2334
+ "groups: a campaign needs at least one asset group, and Google "
2335
+ "requires the asset group and all its assets (headlines, "
2336
+ "descriptions, business name, logo, and images in several aspect "
2337
+ "ratios) in one atomic request. Creating the campaign on its own "
2338
+ "would produce a campaign that can never serve. Create it in the "
2339
+ "Google Ads interface, then use AdLoop to analyse it — "
2340
+ "get_pmax_performance reports per-asset-group Ad Strength."
2341
+ )
2342
+ elif ct != "SEARCH":
2343
+ # These do produce a usable campaign, but nothing here can populate
2344
+ # them: draft_ad_group refuses anything that is not SEARCH.
2345
+ warnings.append(
2346
+ f"{ct} campaigns are created as a shell only. AdLoop can add ad "
2347
+ f"groups, ads and keywords to SEARCH campaigns; finish this one "
2348
+ f"in the Google Ads interface."
2349
+ )
2350
+
2294
2351
  if ct != "SEARCH" and search_partners_enabled:
2295
2352
  errors.append("search_partners_enabled is only supported for SEARCH campaigns")
2296
2353
  if ct != "SEARCH" and display_network_enabled:
@@ -82,6 +82,26 @@ def _resolve_path(path_str: str) -> Path:
82
82
  return Path(os.path.expandvars(os.path.expanduser(path_str)))
83
83
 
84
84
 
85
+ def _text(raw: dict, key: str, default: str = "") -> str:
86
+ """Read a string setting, treating blank as absent.
87
+
88
+ ``raw.get(key, default)`` only falls back when the key is *missing*, so an
89
+ explicit ``token_path: ""`` — easy to produce from a template with blank
90
+ placeholders — passed straight through. ``Path("")`` is ``Path(".")``, the
91
+ working directory always exists, and adloop then tried to read the cwd as
92
+ a token file and died with ``[Errno 1] Operation not permitted: '.'``,
93
+ which points nowhere near the config that caused it.
94
+
95
+ Also coerces non-strings, so ``customer_id: 1234567890`` in YAML arrives
96
+ as text rather than an int.
97
+ """
98
+ value = raw.get(key, default)
99
+
100
+ if value is None:
101
+ return default
102
+
103
+ return str(value).strip() or default
104
+
85
105
  def load_config(config_path: str | None = None) -> AdLoopConfig:
86
106
  """Load configuration from YAML file.
87
107
 
@@ -112,34 +132,34 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
112
132
 
113
133
  return AdLoopConfig(
114
134
  google=GoogleConfig(
115
- project_id=google_raw.get("project_id", ""),
116
- credentials_path=google_raw.get("credentials_path", ""),
117
- token_path=google_raw.get("token_path", "~/.adloop/token.json"),
135
+ project_id=_text(google_raw, "project_id"),
136
+ credentials_path=_text(google_raw, "credentials_path"),
137
+ token_path=_text(google_raw, "token_path", "~/.adloop/token.json"),
118
138
  ),
119
139
  ga4=GA4Config(
120
- property_id=ga4_raw.get("property_id", ""),
140
+ property_id=_text(ga4_raw, "property_id"),
121
141
  ),
122
142
  ads=AdsConfig(
123
- developer_token=ads_raw.get("developer_token", ""),
124
- customer_id=ads_raw.get("customer_id", ""),
125
- login_customer_id=ads_raw.get("login_customer_id", ""),
143
+ developer_token=_text(ads_raw, "developer_token"),
144
+ customer_id=_text(ads_raw, "customer_id"),
145
+ login_customer_id=_text(ads_raw, "login_customer_id"),
126
146
  ),
127
147
  gsc=GscConfig(
128
- site_url=gsc_raw.get("site_url", ""),
148
+ site_url=_text(gsc_raw, "site_url"),
129
149
  ),
130
150
  gtm=GtmConfig(
131
- account_id=str(gtm_raw.get("account_id", "")),
132
- container_id=str(gtm_raw.get("container_id", "")),
151
+ account_id=_text(gtm_raw, "account_id"),
152
+ container_id=_text(gtm_raw, "container_id"),
133
153
  ),
134
154
  pagespeed=PageSpeedConfig(
135
- api_key=str(pagespeed_raw.get("api_key", "")),
155
+ api_key=_text(pagespeed_raw, "api_key"),
136
156
  ),
137
157
  safety=SafetyConfig(
138
158
  max_daily_budget=safety_raw.get("max_daily_budget", 50.0),
139
159
  max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
140
160
  require_dry_run=safety_raw.get("require_dry_run", True),
141
161
  two_phase_apply=safety_raw.get("two_phase_apply", False),
142
- log_file=safety_raw.get("log_file", "~/.adloop/audit.log"),
162
+ log_file=_text(safety_raw, "log_file", "~/.adloop/audit.log"),
143
163
  blocked_operations=safety_raw.get("blocked_operations", []),
144
164
  ),
145
165
  source_path=resolved,
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING
13
13
  if TYPE_CHECKING:
14
14
  from adloop.config import AdLoopConfig
15
15
 
16
- _BASE = "https://merchantapi.googleapis.com/accounts/v1"
16
+ _BASE = "https://merchantapi.googleapis.com"
17
+
18
+ # The Merchant API is split into independently versioned sub-APIs, and the
19
+ # sub-API is part of the path: accounts/v1 serves accounts and account
20
+ # issues, but product-status aggregation lives under issueresolution/v1.
21
+ # Guessing wrong yields a bare 404 with no error message, so every call
22
+ # states its sub-API and API_ACCOUNTS is only the default.
23
+ API_ACCOUNTS = "accounts/v1"
24
+ API_ISSUE_RESOLUTION = "issueresolution/v1"
17
25
 
18
26
  # Google blocks every Merchant API call from a GCP project that is not
19
27
  # registered as a developer with the merchant account (401 UNAUTHENTICATED,
@@ -32,6 +40,7 @@ def _request(
32
40
  method: str,
33
41
  path: str,
34
42
  *,
43
+ api: str = API_ACCOUNTS,
35
44
  params: dict | None = None,
36
45
  json_body: dict | None = None,
37
46
  ) -> dict:
@@ -39,10 +48,11 @@ def _request(
39
48
 
40
49
  from adloop.auth import get_merchant_credentials
41
50
 
51
+ url = f"{_BASE}/{api.strip('/')}/{path.lstrip('/')}"
42
52
  session = AuthorizedSession(get_merchant_credentials(config))
43
53
  response = session.request(
44
54
  method,
45
- f"{_BASE}/{path.lstrip('/')}",
55
+ url,
46
56
  params=params or {},
47
57
  json=json_body,
48
58
  timeout=30,
@@ -55,18 +65,35 @@ def _request(
55
65
  pass
56
66
  if _REGISTRATION_MARKER in detail:
57
67
  raise MerchantNotRegistered(detail)
58
- raise RuntimeError(f"Merchant API returned {response.status_code}: {detail}")
68
+ # A wrong sub-API prefix answers 404 with an empty message, so name
69
+ # the URL that failed rather than reporting a bare status code.
70
+ raise RuntimeError(
71
+ f"Merchant API returned {response.status_code} for {url}"
72
+ f"{f': {detail}' if detail else ''}"
73
+ )
59
74
  return response.json()
60
75
 
61
76
 
62
- def merchant_get(config: AdLoopConfig, path: str, params: dict | None = None) -> dict:
63
- """Authorized GET against the Merchant API accounts_v1 surface."""
64
- return _request(config, "GET", path, params=params)
77
+ def merchant_get(
78
+ config: AdLoopConfig,
79
+ path: str,
80
+ params: dict | None = None,
81
+ *,
82
+ api: str = API_ACCOUNTS,
83
+ ) -> dict:
84
+ """Authorized GET against a Merchant API sub-API (default: accounts)."""
85
+ return _request(config, "GET", path, api=api, params=params)
65
86
 
66
87
 
67
- def merchant_post(config: AdLoopConfig, path: str, body: dict | None = None) -> dict:
68
- """Authorized POST against the Merchant API accounts_v1 surface."""
69
- return _request(config, "POST", path, json_body=body or {})
88
+ def merchant_post(
89
+ config: AdLoopConfig,
90
+ path: str,
91
+ body: dict | None = None,
92
+ *,
93
+ api: str = API_ACCOUNTS,
94
+ ) -> dict:
95
+ """Authorized POST against a Merchant API sub-API (default: accounts)."""
96
+ return _request(config, "POST", path, api=api, json_body=body or {})
70
97
 
71
98
 
72
99
  def register_gcp(config: AdLoopConfig, account_id: str) -> dict:
@@ -118,6 +118,7 @@ def get_merchant_feed_health(
118
118
  them) with account-level issues that can suspend the whole account.
119
119
  """
120
120
  from adloop.merchant.client import (
121
+ API_ISSUE_RESOLUTION,
121
122
  MerchantNotRegistered,
122
123
  merchant_get,
123
124
  register_gcp,
@@ -132,8 +133,12 @@ def get_merchant_feed_health(
132
133
 
133
134
  def _fetch() -> tuple[dict, dict]:
134
135
  return (
136
+ # Product-status aggregation is served by the issueresolution
137
+ # sub-API, not accounts — the wrong prefix 404s.
135
138
  merchant_get(
136
- config, f"accounts/{account_id}/aggregateProductStatuses"
139
+ config,
140
+ f"accounts/{account_id}/aggregateProductStatuses",
141
+ api=API_ISSUE_RESOLUTION,
137
142
  ),
138
143
  merchant_get(config, f"accounts/{account_id}/issues"),
139
144
  )
@@ -655,16 +655,29 @@ For cities/regions, use `run_gaql` with `geo_target_constant` resource or look u
655
655
 
656
656
  ### Common Language IDs (languageConstants)
657
657
 
658
- | ID | Language |
659
- |----|----------|
660
- | 1000 | English |
661
- | 1001 | German |
662
- | 1002 | French |
663
- | 1003 | Italian |
664
- | 1004 | Spanish |
665
- | 1005 | Dutch |
666
- | 1009 | Portuguese |
667
- | 1014 | Polish |
658
+ Verified against the `language_constant` resource. Google accepts a wrong
659
+ language ID silently, so confirm anything not listed here with:
660
+ `SELECT language_constant.id, language_constant.code, language_constant.name FROM language_constant`
661
+
662
+ | ID | Code | Language |
663
+ |----|------|----------|
664
+ | 1000 | en | English |
665
+ | 1001 | de | German |
666
+ | 1002 | fr | French |
667
+ | 1003 | es | Spanish |
668
+ | 1004 | it | Italian |
669
+ | 1005 | ja | Japanese |
670
+ | 1009 | da | Danish |
671
+ | 1010 | nl | Dutch |
672
+ | 1011 | fi | Finnish |
673
+ | 1014 | pt | Portuguese |
674
+ | 1015 | sv | Swedish |
675
+ | 1017 | zh_CN | Chinese (simplified) |
676
+ | 1019 | ar | Arabic |
677
+ | 1021 | cs | Czech |
678
+ | 1030 | pl | Polish |
679
+ | 1031 | ru | Russian |
680
+ | 1037 | tr | Turkish |
668
681
 
669
682
  ## Marketing Best Practices
670
683