adloop 0.35.0__tar.gz → 0.36.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 (76) hide show
  1. {adloop-0.35.0 → adloop-0.36.0}/PKG-INFO +5 -3
  2. {adloop-0.35.0 → adloop-0.36.0}/README.md +4 -2
  3. {adloop-0.35.0 → adloop-0.36.0}/pyproject.toml +1 -1
  4. {adloop-0.35.0 → adloop-0.36.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/assets.py +35 -4
  6. adloop-0.36.0/src/adloop/ads/campaign_settings.py +222 -0
  7. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/read.py +4 -0
  8. adloop-0.36.0/src/adloop/ads/recommendations.py +210 -0
  9. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/write.py +148 -2
  10. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/cli.py +10 -7
  11. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/adloop.md +8 -3
  12. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules_install.py +66 -22
  13. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/server.py +101 -7
  14. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/__init__.py +0 -0
  15. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/__main__.py +0 -0
  16. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/_mcp_patches.py +0 -0
  17. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/__init__.py +0 -0
  18. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/ai_max.py +0 -0
  19. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/audiences.py +0 -0
  20. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/brands.py +0 -0
  21. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/client.py +0 -0
  22. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/conversion_actions.py +0 -0
  23. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/currency.py +0 -0
  24. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  25. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/enums.py +0 -0
  26. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/forecast.py +0 -0
  27. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/gaql.py +0 -0
  28. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/image_fetch.py +0 -0
  29. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/pmax.py +0 -0
  30. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/shopping.py +0 -0
  31. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/snippet_headers.py +0 -0
  32. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ads/validate_only.py +0 -0
  33. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/auth.py +0 -0
  34. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/config.py +0 -0
  35. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/crossref.py +0 -0
  36. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/diagnostics.py +0 -0
  37. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/__init__.py +0 -0
  38. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/client.py +0 -0
  39. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/reports.py +0 -0
  40. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/tracking.py +0 -0
  41. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/ga4/write.py +0 -0
  42. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/__init__.py +0 -0
  43. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/client.py +0 -0
  44. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gsc/reports.py +0 -0
  45. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/__init__.py +0 -0
  46. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/client.py +0 -0
  47. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/entities.py +0 -0
  48. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/read.py +0 -0
  49. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/gtm/write.py +0 -0
  50. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/__init__.py +0 -0
  51. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/client.py +0 -0
  52. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/merchant/read.py +0 -0
  53. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/pagespeed.py +0 -0
  54. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/__init__.py +0 -0
  55. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/auth.py +0 -0
  56. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/client.py +0 -0
  57. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/read.py +0 -0
  58. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/schedule.py +0 -0
  59. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/reddit/write.py +0 -0
  60. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/__init__.py +0 -0
  61. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  62. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  63. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/create-ad.md +0 -0
  64. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  65. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  66. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  67. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/runtime.py +0 -0
  68. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/__init__.py +0 -0
  69. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/approval.py +0 -0
  70. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/approval_card.html +0 -0
  71. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/audit.py +0 -0
  72. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/guards.py +0 -0
  73. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/safety/preview.py +0 -0
  74. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/tracking.py +0 -0
  75. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/upload_card.html +0 -0
  76. {adloop-0.35.0 → adloop-0.36.0}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.35.0
3
+ Version: 0.36.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -148,6 +148,7 @@ The best features come from real workflows. If you're using AdLoop and find your
148
148
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
149
149
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
150
150
  | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
151
+ | `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
151
152
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
152
153
  | `run_gaql` | Arbitrary GAQL queries for anything else |
153
154
 
@@ -334,6 +335,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
334
335
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
335
336
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
336
337
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
338
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
337
339
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
338
340
  | `enable_entity` | Re-enable a paused entity |
339
341
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -523,8 +525,8 @@ adloop install-rules
523
525
 
524
526
  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:
525
527
 
526
- - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
527
- - **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.
528
+ - **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
529
+ - **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
528
530
 
529
531
  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.
530
532
 
@@ -120,6 +120,7 @@ The best features come from real workflows. If you're using AdLoop and find your
120
120
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
121
121
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
122
122
  | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
123
+ | `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
123
124
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
124
125
  | `run_gaql` | Arbitrary GAQL queries for anything else |
125
126
 
@@ -306,6 +307,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
306
307
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
307
308
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
308
309
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
310
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
309
311
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
310
312
  | `enable_entity` | Re-enable a paused entity |
311
313
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -495,8 +497,8 @@ adloop install-rules
495
497
 
496
498
  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:
497
499
 
498
- - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
499
- - **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.
500
+ - **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
501
+ - **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
500
502
 
501
503
  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.
502
504
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.0"
3
+ version = "0.36.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.0"
3
+ version = "0.36.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1415,13 +1415,14 @@ def draft_promotion(
1415
1415
  scope: str = "",
1416
1416
  language_code: str = "",
1417
1417
  currency_code: str = "",
1418
+ ad_schedule: list[dict] | None = None,
1418
1419
  **promotion,
1419
1420
  ) -> dict:
1420
1421
  """Draft a promotion asset linked to an ad group, a campaign or the account.
1421
1422
 
1422
1423
  ``promotion`` takes promotion_target, final_url, money_off or percent_off,
1423
1424
  promotion_code or orders_over_amount, occasion, discount_modifier and the
1424
- four dates.
1425
+ four dates. ``ad_schedule`` limits the hours it shows.
1425
1426
  """
1426
1427
  from adloop.safety.preview import ChangePlan, store_plan
1427
1428
 
@@ -1430,6 +1431,8 @@ def draft_promotion(
1430
1431
  scope, campaign_id, ad_group_id, errors = resolve_scope(scope, campaign_id, ad_group_id)
1431
1432
  fields, parse_errors = _promotion_input(**promotion)
1432
1433
  fields["language_code"] = _language(language_code, errors)
1434
+ fields["ad_schedule"], schedule_errors = _validate_schedule(ad_schedule or [])
1435
+ errors += schedule_errors
1433
1436
  errors += parse_errors or _check_promotion(fields)
1434
1437
  if errors:
1435
1438
  return {"error": "Validation failed", "details": errors}
@@ -1456,6 +1459,8 @@ def draft_promotion(
1456
1459
  preview = plan.to_preview()
1457
1460
  preview["warnings"] = [*warnings, *_promotion_warnings(fields), *url_warnings] + (
1458
1461
  [_account_scope_warning("promotions")] if scope == "account" else [])
1462
+ if fields["ad_schedule"]:
1463
+ preview["warnings"].append(_time_zone_note(_account_settings(config, customer_id)["time_zone"]))
1459
1464
  return preview
1460
1465
 
1461
1466
 
@@ -1472,8 +1477,10 @@ _PROMOTION_FIELDS = {
1472
1477
  "discount_modifier": "asset.promotion_asset.discount_modifier",
1473
1478
  "language_code": "asset.promotion_asset.language_code",
1474
1479
  **{field: f"asset.promotion_asset.{field}" for field in _DATE_FIELDS},
1480
+ "ad_schedule": "asset.promotion_asset.ad_schedule_targets",
1475
1481
  }
1476
- _CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS)
1482
+ _CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS,
1483
+ "ad_schedule")
1477
1484
 
1478
1485
 
1479
1486
  def _promotion_from_google(raw: dict) -> dict:
@@ -1490,6 +1497,7 @@ def _promotion_from_google(raw: dict) -> dict:
1490
1497
  "discount_modifier": raw["discount_modifier"],
1491
1498
  "language_code": raw["language_code"],
1492
1499
  **{field: raw[field] for field in _DATE_FIELDS},
1500
+ "ad_schedule": _schedule_from_google(raw["ad_schedule"]),
1493
1501
  }
1494
1502
 
1495
1503
 
@@ -1501,9 +1509,14 @@ def update_promotion(
1501
1509
  language_code: str = "",
1502
1510
  currency_code: str = "",
1503
1511
  clear_fields: list[str] | None = None,
1512
+ ad_schedule: list[dict] | None = None,
1504
1513
  **promotion,
1505
1514
  ) -> dict:
1506
- """Draft an in-place edit of a promotion asset; fields not passed stay as they are."""
1515
+ """Draft an in-place edit of a promotion asset; fields not passed stay as they are.
1516
+
1517
+ A non-empty ``ad_schedule`` replaces the promotion's schedule;
1518
+ ``clear_fields=["ad_schedule"]`` removes it.
1519
+ """
1507
1520
  if blocked := _blocked("update_promotion", config):
1508
1521
  return blocked
1509
1522
  asset_id = _clean(asset_id)
@@ -1511,6 +1524,10 @@ def update_promotion(
1511
1524
  passed, parse_errors = _promotion_input(**promotion)
1512
1525
  errors += parse_errors
1513
1526
  passed = {k: v for k, v in passed.items() if v}
1527
+ windows, schedule_errors = _validate_schedule(ad_schedule or [])
1528
+ errors += schedule_errors
1529
+ if windows:
1530
+ passed["ad_schedule"] = windows
1514
1531
  if _clean(language_code):
1515
1532
  passed["language_code"] = _language(language_code, errors)
1516
1533
  clear = [_clean(f).lower() for f in clear_fields or []]
@@ -1539,7 +1556,7 @@ def update_promotion(
1539
1556
  if "orders_over_amount" in passed:
1540
1557
  merged["promotion_code"] = ""
1541
1558
  for field in clear:
1542
- merged[field] = 0.0 if field == "orders_over_amount" else ""
1559
+ merged[field] = {"orders_over_amount": 0.0, "ad_schedule": []}.get(field, "")
1543
1560
  errors = _check_promotion(merged)
1544
1561
  if errors:
1545
1562
  return {"error": "Validation failed", "details": errors}
@@ -1565,6 +1582,10 @@ def update_promotion(
1565
1582
  {"set": changed, "promotion": merged},
1566
1583
  {k: current[k] for k in changed}, pinned={k: raw[k] for k in _PROMOTION_FIELDS})
1567
1584
  preview["warnings"] += warnings + _promotion_warnings(merged)
1585
+ if "ad_schedule" in changed:
1586
+ preview["warnings"].append(
1587
+ _time_zone_note(_account_settings(config, customer_id)["time_zone"]) if merged["ad_schedule"]
1588
+ else "Without a schedule the promotion shows at all hours the campaign serves.")
1568
1589
  return preview
1569
1590
 
1570
1591
 
@@ -1589,6 +1610,10 @@ def _populate_promotion(client, asset, promo: dict) -> None:
1589
1610
  for field in _DATE_FIELDS:
1590
1611
  if promo.get(field):
1591
1612
  setattr(p, field, promo[field])
1613
+ for window in promo.get("ad_schedule") or []:
1614
+ info = client.get_type("AdScheduleInfo")
1615
+ _populate_schedule(client, info, window)
1616
+ p.ad_schedule_targets.append(info)
1592
1617
  asset.final_urls.append(promo["final_url"])
1593
1618
 
1594
1619
 
@@ -1638,6 +1663,12 @@ def _apply_update_promotion(client, cid: str, changes: dict) -> dict:
1638
1663
  if promo[field]:
1639
1664
  setattr(p, field, promo[field])
1640
1665
  paths.append(f"promotion_asset.{field}")
1666
+ if "ad_schedule" in changed: # replaces the whole list; empty clears it
1667
+ for window in promo["ad_schedule"]:
1668
+ info = client.get_type("AdScheduleInfo")
1669
+ _populate_schedule(client, info, window)
1670
+ p.ad_schedule_targets.append(info)
1671
+ paths.append("promotion_asset.ad_schedule_targets")
1641
1672
  return list(dict.fromkeys(paths))
1642
1673
 
1643
1674
  return _apply_asset_update(client, cid, changes["asset_id"], set_fields)
@@ -0,0 +1,222 @@
1
+ """One read for the settings that decide how far a Search campaign reaches.
2
+
3
+ A recurring audit ("has Google switched anything back on?") otherwise needs
4
+ ``get_ai_max_settings`` plus hand-written GAQL for networks, location option,
5
+ campaign-level broad match and URL options. Every field below was checked
6
+ against the v25 ``campaign`` resource:
7
+
8
+ campaign.network_settings.target_google_search / target_search_network /
9
+ target_content_network / target_partner_search_network
10
+ campaign.geo_target_type_setting.positive_geo_target_type PRESENCE_OR_INTEREST,
11
+ SEARCH_INTEREST, PRESENCE
12
+ campaign.geo_target_type_setting.negative_geo_target_type PRESENCE_OR_INTEREST, PRESENCE
13
+ campaign.keyword_match_type BROAD, or unset
14
+ campaign.tracking_url_template, final_url_suffix, url_custom_parameters
15
+ campaign.bidding_strategy_type and the per-strategy targets
16
+ campaign_budget.amount_micros, explicitly_shared
17
+
18
+ The AI Max half comes from ``ai_max.read_ai_max_state`` unchanged, so both tools
19
+ always agree. Read-only: nothing here mutates.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from adloop.config import AdLoopConfig
28
+
29
+ _SETTINGS_SELECT = """
30
+ SELECT campaign.id, campaign.name, campaign.status,
31
+ campaign.advertising_channel_type,
32
+ campaign.bidding_strategy_type, campaign.bidding_strategy,
33
+ campaign.target_spend.cpc_bid_ceiling_micros,
34
+ campaign.maximize_conversions.target_cpa_micros,
35
+ campaign.target_cpa.target_cpa_micros,
36
+ campaign.target_roas.target_roas,
37
+ campaign.maximize_conversion_value.target_roas,
38
+ campaign.network_settings.target_google_search,
39
+ campaign.network_settings.target_search_network,
40
+ campaign.network_settings.target_content_network,
41
+ campaign.geo_target_type_setting.positive_geo_target_type,
42
+ campaign.geo_target_type_setting.negative_geo_target_type,
43
+ campaign.keyword_match_type,
44
+ campaign.tracking_url_template, campaign.final_url_suffix,
45
+ campaign.url_custom_parameters,
46
+ campaign_budget.amount_micros, campaign_budget.explicitly_shared
47
+ FROM campaign
48
+ WHERE {where}
49
+ ORDER BY campaign.name
50
+ """
51
+
52
+ _SEARCH_ONLY = "campaign.advertising_channel_type = 'SEARCH'"
53
+ _WHERE_ALL = f"campaign.status != 'REMOVED' AND {_SEARCH_ONLY}"
54
+
55
+ # Google's default; it also reaches people elsewhere who search *about* the
56
+ # targeted locations.
57
+ _WIDE_LOCATION_OPTIONS = {"PRESENCE_OR_INTEREST", "SEARCH_INTEREST"}
58
+
59
+
60
+ def _money(micros: object) -> float | None:
61
+ """Micros to account currency; 0 or missing means "not set"."""
62
+ if not micros:
63
+ return None
64
+ return round(int(micros) / 1_000_000, 2)
65
+
66
+
67
+ def _target_cpa(row: dict) -> float | None:
68
+ return _money(
69
+ row.get("campaign.target_cpa.target_cpa_micros")
70
+ or row.get("campaign.maximize_conversions.target_cpa_micros")
71
+ )
72
+
73
+
74
+ def _target_roas(row: dict) -> float | None:
75
+ value = row.get("campaign.target_roas.target_roas") or row.get(
76
+ "campaign.maximize_conversion_value.target_roas"
77
+ )
78
+ return value or None
79
+
80
+
81
+ def _custom_parameters(value: object) -> list[dict]:
82
+ if not isinstance(value, list):
83
+ return []
84
+ return [
85
+ {"key": item.get("key", ""), "value": item.get("value", "")}
86
+ for item in value
87
+ if isinstance(item, dict)
88
+ ]
89
+
90
+
91
+ def _widening(campaign: dict) -> list[str]:
92
+ """The settings on this campaign that let ads reach beyond what was chosen."""
93
+ found: list[str] = []
94
+ if campaign["search_partners"]:
95
+ found.append("search partners on")
96
+ if campaign["display_network"]:
97
+ found.append("Display network on")
98
+ if campaign["location_option"] in _WIDE_LOCATION_OPTIONS:
99
+ found.append(f"location option {campaign['location_option']}")
100
+ if campaign["keyword_match_type"] == "BROAD":
101
+ found.append("campaign-level broad match on")
102
+ if campaign["enable_ai_max"]:
103
+ found.append("AI Max on")
104
+ for automation, status in campaign["asset_automation"].items():
105
+ if status == "OPTED_IN":
106
+ found.append(f"{automation} opted in")
107
+ return found
108
+
109
+
110
+ def get_campaign_settings(
111
+ config: AdLoopConfig,
112
+ *,
113
+ customer_id: str = "",
114
+ campaign_id: str = "",
115
+ ) -> dict:
116
+ """Read-only, one flat record per Search campaign. See the module docstring."""
117
+ from adloop.ads.client import get_ads_client, normalize_customer_id
118
+ from adloop.ads.currency import get_currency_code
119
+
120
+ campaign_id = str(campaign_id or "").strip()
121
+ if campaign_id and not campaign_id.isdigit():
122
+ return {"error": "campaign_id must be a numeric ID"}
123
+
124
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
125
+ client = get_ads_client(config)
126
+ result = read_campaign_settings(client, cid, campaign_id=campaign_id)
127
+ result["currency"] = get_currency_code(config, cid)
128
+ return result
129
+
130
+
131
+ def read_campaign_settings(client: object, cid: str, *, campaign_id: str = "") -> dict:
132
+ """Run both reads and merge them per campaign."""
133
+ from adloop.ads.ai_max import _search, read_ai_max_state
134
+
135
+ if campaign_id and not str(campaign_id).isdigit():
136
+ # The id goes into the query text; anything else must not reach GAQL.
137
+ raise ValueError("campaign_id must be a numeric ID")
138
+ # An explicit ID stays Search-only too: the AI Max and location settings
139
+ # read here don't apply to Performance Max or Display campaigns.
140
+ where = f"campaign.id = {campaign_id} AND {_SEARCH_ONLY}" if campaign_id else _WHERE_ALL
141
+ rows = _search(
142
+ client.get_service("GoogleAdsService"), cid, _SETTINGS_SELECT.format(where=where)
143
+ )
144
+ ai_max = {
145
+ c["campaign_id"]: c
146
+ for c in read_ai_max_state(client, cid, campaign_id=campaign_id)["campaigns"]
147
+ }
148
+
149
+ campaigns: list[dict] = []
150
+ for row in rows:
151
+ key = str(row.get("campaign.id", ""))
152
+ if not key or (campaign_id and key != str(campaign_id)):
153
+ continue
154
+ ai = ai_max.get(key, {})
155
+ campaign = {
156
+ "campaign_id": key,
157
+ "campaign_name": row.get("campaign.name"),
158
+ "status": row.get("campaign.status"),
159
+ "advertising_channel_type": row.get("campaign.advertising_channel_type"),
160
+ "bidding_strategy_type": row.get("campaign.bidding_strategy_type"),
161
+ "portfolio_bidding_strategy": row.get("campaign.bidding_strategy") or None,
162
+ "cpc_bid_ceiling": _money(
163
+ row.get("campaign.target_spend.cpc_bid_ceiling_micros")
164
+ ),
165
+ "target_cpa": _target_cpa(row),
166
+ "target_roas": _target_roas(row),
167
+ "daily_budget": _money(row.get("campaign_budget.amount_micros")),
168
+ "budget_shared": bool(row.get("campaign_budget.explicitly_shared")),
169
+ "google_search": bool(row.get("campaign.network_settings.target_google_search")),
170
+ "search_partners": bool(
171
+ row.get("campaign.network_settings.target_search_network")
172
+ ),
173
+ "display_network": bool(
174
+ row.get("campaign.network_settings.target_content_network")
175
+ ),
176
+ "location_option": row.get(
177
+ "campaign.geo_target_type_setting.positive_geo_target_type"
178
+ ),
179
+ "negative_location_option": row.get(
180
+ "campaign.geo_target_type_setting.negative_geo_target_type"
181
+ ),
182
+ "keyword_match_type": row.get("campaign.keyword_match_type"),
183
+ "tracking_url_template": row.get("campaign.tracking_url_template") or "",
184
+ "final_url_suffix": row.get("campaign.final_url_suffix") or "",
185
+ "url_custom_parameters": _custom_parameters(
186
+ row.get("campaign.url_custom_parameters")
187
+ ),
188
+ "enable_ai_max": bool(ai.get("enable_ai_max")),
189
+ "asset_automation": {
190
+ item["asset_automation_type"]: item.get("asset_automation_status", "")
191
+ for item in ai.get("asset_automation_settings", [])
192
+ },
193
+ # Search term matching only acts under AI Max; listing every ad
194
+ # group's default False otherwise would be noise.
195
+ "ad_groups_with_search_term_matching": [
196
+ g["ad_group_name"]
197
+ for g in ai.get("ad_groups", [])
198
+ if g.get("disable_search_term_matching") is False
199
+ ] if ai.get("enable_ai_max") else [],
200
+ }
201
+ campaign["widening_settings"] = _widening(campaign)
202
+ campaigns.append(campaign)
203
+
204
+ insights: list[str] = []
205
+ for campaign in campaigns:
206
+ if campaign["widening_settings"]:
207
+ insights.append(
208
+ f"{campaign['campaign_name']} ({campaign['campaign_id']}): "
209
+ + ", ".join(campaign["widening_settings"])
210
+ + ". These let the ads reach beyond the keywords and locations "
211
+ "chosen; confirm they are intended."
212
+ )
213
+ if campaigns and not insights:
214
+ insights.append("No setting that widens reach is on in any campaign returned.")
215
+ if not campaigns:
216
+ insights.append("No non-removed Search campaign matched.")
217
+
218
+ return {
219
+ "campaigns": campaigns,
220
+ "total_campaigns": len(campaigns),
221
+ "insights": insights,
222
+ }
@@ -843,6 +843,10 @@ def get_recommendations(
843
843
  pot_conversions = _round_metric(getattr(pot, "conversions", 0))
844
844
 
845
845
  entry = {
846
+ # The ID (last segment of the resource name) is what
847
+ # draft_dismiss_recommendations takes.
848
+ "recommendation.id": rec.resource_name.rsplit("/", 1)[-1],
849
+ "recommendation.resource_name": rec.resource_name,
846
850
  "recommendation.type": rec_type,
847
851
  "recommendation.campaign": rec.campaign,
848
852
  "recommendation.ad_group": rec.ad_group or "",
@@ -0,0 +1,210 @@
1
+ """Dismiss Google Ads recommendations.
2
+
3
+ ``get_recommendations`` (in ``ads/read.py``) lists the active ones; this module
4
+ turns a chosen subset down. The v25 call (verified against the SDK):
5
+
6
+ RecommendationService.DismissRecommendation
7
+ DismissRecommendationRequest{customer_id, operations[], partial_failure}
8
+ DismissRecommendationOperation{resource_name}
9
+
10
+ The request has **no** ``validate_only`` field, so a dry run cannot hand the
11
+ exact call to Google the way other Google Ads writes do. Instead the dry run
12
+ re-reads the account and checks that every planned recommendation is still
13
+ active (``preflight``), the same approach the Reddit, Tag Manager and GA4
14
+ writes use. The validate-only client still refuses the call outright, so this
15
+ path can never dismiss anything during a dry run.
16
+
17
+ Dismissing is mild: nothing in the account changes, and Google can show the
18
+ recommendation again later. A single confirmation is enough.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from adloop.config import AdLoopConfig
28
+
29
+ OPERATION = "dismiss_recommendations"
30
+
31
+ # A recommendation ID is the last segment of its resource name. Google does not
32
+ # document the alphabet, so accept what resource names can carry and nothing
33
+ # that could break out of one.
34
+ _ID_RE = re.compile(r"^[A-Za-z0-9_~=+-]+$")
35
+ _RESOURCE_RE = re.compile(r"^customers/(\d+)/recommendations/([A-Za-z0-9_~=+-]+)$")
36
+
37
+
38
+ def recommendation_id(resource_name: str) -> str:
39
+ """``customers/1/recommendations/abc`` -> ``abc``."""
40
+ return str(resource_name or "").rsplit("/", 1)[-1]
41
+
42
+
43
+ def _clean_ids(values: list[str] | None, cid: str) -> tuple[list[str], list[str]]:
44
+ """Accept bare IDs or full resource names. Returns (errors, deduped IDs)."""
45
+ errors: list[str] = []
46
+ ids: list[str] = []
47
+ for raw in values or []:
48
+ value = str(raw or "").strip()
49
+ if not value:
50
+ continue
51
+ match = _RESOURCE_RE.match(value)
52
+ if match:
53
+ if match.group(1) != cid:
54
+ errors.append(
55
+ f"{value} belongs to account {match.group(1)}, not {cid}"
56
+ )
57
+ continue
58
+ value = match.group(2)
59
+ elif not _ID_RE.match(value):
60
+ errors.append(f"'{value}' is not a recommendation ID or resource name")
61
+ continue
62
+ if value not in ids:
63
+ ids.append(value)
64
+ return errors, ids
65
+
66
+
67
+ def _active_by_id(config: AdLoopConfig, customer_id: str) -> dict[str, dict]:
68
+ """Every active (not dismissed) recommendation, keyed by ID."""
69
+ from adloop.ads.read import get_recommendations
70
+
71
+ rows = get_recommendations(config, customer_id=customer_id).get(
72
+ "recommendations", []
73
+ )
74
+ return {
75
+ recommendation_id(row.get("recommendation.resource_name", "")): row
76
+ for row in rows
77
+ if row.get("recommendation.resource_name")
78
+ }
79
+
80
+
81
+ def draft_dismiss_recommendations(
82
+ config: AdLoopConfig,
83
+ *,
84
+ customer_id: str = "",
85
+ recommendation_ids: list[str] | None = None,
86
+ ) -> dict:
87
+ """Draft dismissing recommendations — returns PREVIEW.
88
+
89
+ Reads the active recommendations first: an ID that is unknown or already
90
+ dismissed is refused by name, so the preview only ever lists what the
91
+ apply will turn down. Each entry carries its type, campaign and Google's
92
+ estimated improvement, the same numbers ``get_recommendations`` shows.
93
+
94
+ Call ``confirm_and_apply`` with the returned plan_id to execute.
95
+ """
96
+ from adloop.ads.client import normalize_customer_id
97
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
98
+ from adloop.safety.preview import ChangePlan, store_plan
99
+
100
+ try:
101
+ check_blocked_operation(OPERATION, config.safety)
102
+ except SafetyViolation as e:
103
+ return {"error": str(e)}
104
+
105
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
106
+ errors, ids = _clean_ids(recommendation_ids, cid)
107
+ if not ids and not errors:
108
+ errors.append("At least one recommendation_id is required")
109
+ if errors:
110
+ return {"error": "Validation failed", "details": errors}
111
+
112
+ active = _active_by_id(config, customer_id)
113
+ missing = [rec_id for rec_id in ids if rec_id not in active]
114
+ if missing:
115
+ return {
116
+ "error": "Validation failed",
117
+ "details": [
118
+ "Not an active recommendation in this account (unknown, or "
119
+ "already dismissed): " + ", ".join(missing)
120
+ + ". get_recommendations lists the active ones with their IDs."
121
+ ],
122
+ }
123
+
124
+ recommendations = [
125
+ {
126
+ "recommendation_id": rec_id,
127
+ "resource_name": active[rec_id]["recommendation.resource_name"],
128
+ "type": active[rec_id].get("recommendation.type", ""),
129
+ "campaign": active[rec_id].get("recommendation.campaign", ""),
130
+ "ad_group": active[rec_id].get("recommendation.ad_group", ""),
131
+ "estimated_improvement": active[rec_id].get("estimated_improvement", {}),
132
+ }
133
+ for rec_id in ids
134
+ ]
135
+ plan = ChangePlan(
136
+ operation=OPERATION,
137
+ entity_type="recommendation",
138
+ entity_id=",".join(ids),
139
+ customer_id=customer_id,
140
+ changes={
141
+ "recommendations": recommendations,
142
+ "count": len(recommendations),
143
+ "note": (
144
+ "Dismissing changes nothing in the account. Google can show a "
145
+ "dismissed recommendation again later."
146
+ ),
147
+ },
148
+ )
149
+ store_plan(plan)
150
+ return plan.to_preview()
151
+
152
+
153
+ def preflight(config: AdLoopConfig, plan: object) -> dict:
154
+ """Dry run: every planned recommendation must still be active.
155
+
156
+ DismissRecommendation has no validate-only mode, so this re-read is the
157
+ whole check. Raises when any recommendation has gone (dismissed in the UI,
158
+ applied, or expired), so the dry run fails instead of reporting success.
159
+ """
160
+ planned = [r["recommendation_id"] for r in plan.changes["recommendations"]]
161
+ active = _active_by_id(config, plan.customer_id)
162
+ gone = [rec_id for rec_id in planned if rec_id not in active]
163
+ if gone:
164
+ raise ValueError(
165
+ "No longer active (dismissed, applied or expired since the "
166
+ "preview): " + ", ".join(gone) + ". Draft again."
167
+ )
168
+ return {"recommendations_checked": len(planned), "still_active": len(planned)}
169
+
170
+
171
+ def _apply_dismiss_recommendations(client: object, cid: str, changes: dict) -> dict:
172
+ """Dismiss the planned recommendations; per-recommendation errors are reported."""
173
+ from adloop.ads.write import _parse_partial_failure_per_op
174
+
175
+ names = [r["resource_name"] for r in changes["recommendations"]]
176
+ service = client.get_service("RecommendationService")
177
+ request = client.get_type("DismissRecommendationRequest")
178
+ request.customer_id = cid
179
+ request.partial_failure = True
180
+ operation_type = type(request).DismissRecommendationOperation
181
+ for name in names:
182
+ request.operations.append(operation_type(resource_name=name))
183
+
184
+ response = service.dismiss_recommendation(request=request)
185
+
186
+ pf_error = getattr(response, "partial_failure_error", None)
187
+ per_op_errors = _parse_partial_failure_per_op(client, pf_error)
188
+ dismissed: list[str] = []
189
+ failed: list[dict] = []
190
+ for index, result in enumerate(response.results):
191
+ if result.resource_name:
192
+ dismissed.append(result.resource_name)
193
+ else:
194
+ failed.append(
195
+ {
196
+ "recommendation_id": recommendation_id(names[index]),
197
+ "error": per_op_errors.get(
198
+ index, "Unknown error (see partial_failure_message)"
199
+ ),
200
+ }
201
+ )
202
+
203
+ out: dict = {"dismissed": dismissed, "dismissed_count": len(dismissed)}
204
+ if failed:
205
+ out["partial_failure"] = True
206
+ out["failed"] = failed
207
+ message = getattr(pf_error, "message", "") if pf_error is not None else ""
208
+ if message:
209
+ out["partial_failure_message"] = message
210
+ return out