adloop 0.35.1__tar.gz → 0.37.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 (77) hide show
  1. {adloop-0.35.1 → adloop-0.37.0}/PKG-INFO +9 -5
  2. {adloop-0.35.1 → adloop-0.37.0}/README.md +8 -4
  3. {adloop-0.35.1 → adloop-0.37.0}/pyproject.toml +1 -1
  4. {adloop-0.35.1 → adloop-0.37.0}/pyproject.toml.orig +1 -1
  5. adloop-0.37.0/src/adloop/ads/campaign_settings.py +222 -0
  6. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/read.py +4 -0
  7. adloop-0.37.0/src/adloop/ads/recommendation_subscriptions.py +202 -0
  8. adloop-0.37.0/src/adloop/ads/recommendations.py +210 -0
  9. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/write.py +327 -2
  10. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/cli.py +10 -7
  11. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/read.py +14 -0
  12. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/write.py +62 -0
  13. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/adloop.md +13 -5
  14. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules_install.py +66 -22
  15. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/server.py +223 -4
  16. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/__init__.py +0 -0
  17. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/__main__.py +0 -0
  18. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/_mcp_patches.py +0 -0
  19. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/__init__.py +0 -0
  20. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/ai_max.py +0 -0
  21. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/assets.py +0 -0
  22. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/audiences.py +0 -0
  23. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/brands.py +0 -0
  24. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/client.py +0 -0
  25. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/conversion_actions.py +0 -0
  26. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/currency.py +0 -0
  27. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  28. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/enums.py +0 -0
  29. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/forecast.py +0 -0
  30. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/gaql.py +0 -0
  31. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/image_fetch.py +0 -0
  32. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/pmax.py +0 -0
  33. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/shopping.py +0 -0
  34. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/snippet_headers.py +0 -0
  35. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/validate_only.py +0 -0
  36. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/auth.py +0 -0
  37. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/config.py +0 -0
  38. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/crossref.py +0 -0
  39. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/diagnostics.py +0 -0
  40. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/__init__.py +0 -0
  41. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/client.py +0 -0
  42. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/reports.py +0 -0
  43. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/tracking.py +0 -0
  44. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/write.py +0 -0
  45. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/__init__.py +0 -0
  46. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/client.py +0 -0
  47. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/reports.py +0 -0
  48. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/__init__.py +0 -0
  49. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/client.py +0 -0
  50. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/entities.py +0 -0
  51. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/read.py +0 -0
  52. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/write.py +0 -0
  53. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/__init__.py +0 -0
  54. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/client.py +0 -0
  55. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/read.py +0 -0
  56. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/pagespeed.py +0 -0
  57. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/__init__.py +0 -0
  58. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/auth.py +0 -0
  59. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/client.py +0 -0
  60. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/schedule.py +0 -0
  61. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/__init__.py +0 -0
  62. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  63. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  64. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/create-ad.md +0 -0
  65. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  66. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  67. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  68. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/runtime.py +0 -0
  69. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/__init__.py +0 -0
  70. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/approval.py +0 -0
  71. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/approval_card.html +0 -0
  72. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/audit.py +0 -0
  73. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/guards.py +0 -0
  74. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/preview.py +0 -0
  75. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/tracking.py +0 -0
  76. {adloop-0.35.1 → adloop-0.37.0}/src/adloop/upload_card.html +0 -0
  77. {adloop-0.35.1 → adloop-0.37.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.1
3
+ Version: 0.37.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
@@ -131,6 +131,7 @@ The best features come from real workflows. If you're using AdLoop and find your
131
131
  | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
132
132
  | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
133
133
  | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
134
+ | `get_recommendation_subscriptions` | Which recommendation types Google applies automatically (auto-apply), with status and last change; flags any that are on |
134
135
  | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
135
136
  | `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content |
136
137
  | `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
@@ -148,6 +149,7 @@ The best features come from real workflows. If you're using AdLoop and find your
148
149
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
149
150
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
150
151
  | `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` |
152
+ | `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
153
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
152
154
  | `run_gaql` | Arbitrary GAQL queries for anything else |
153
155
 
@@ -296,12 +298,12 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
296
298
 
297
299
  | Tool | What It Does |
298
300
  |------|-------------|
299
- | `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
301
+ | `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, the location option (presence vs presence or interest), campaign URL options (tracking template, final URL suffix, custom parameters), and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
300
302
  | `draft_shopping_campaign` | Create a Standard Shopping campaign for a linked Merchant Center feed — budget, campaign (PAUSED), product ad group, Shopping ad and an "All products" group in one atomic request. Manual CPC, Maximize clicks or target ROAS. |
301
303
  | `draft_asset_group_assets` | Add and remove Performance Max assets (text, images incl. uploads, YouTube videos) in one change, so replacing an asset never drops a group below Google's minimums |
302
304
  | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group |
303
305
  | `draft_pmax_product_exclusions` | Exclude products from a retail PMax asset group by item id, brand or custom label |
304
- | `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
306
+ | `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, the location option, campaign URL options, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
305
307
  | `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
306
308
  | `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
307
309
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
@@ -334,6 +336,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
334
336
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
335
337
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
336
338
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
339
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
340
+ | `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types, so Google stops changing the account without a preview. Switching auto-apply back on stays in the Google Ads UI |
337
341
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
338
342
  | `enable_entity` | Re-enable a paused entity |
339
343
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -523,8 +527,8 @@ adloop install-rules
523
527
 
524
528
  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
529
 
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.
530
+ - **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.
531
+ - **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
532
 
529
533
  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
534
 
@@ -103,6 +103,7 @@ The best features come from real workflows. If you're using AdLoop and find your
103
103
  | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
104
104
  | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
105
105
  | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
106
+ | `get_recommendation_subscriptions` | Which recommendation types Google applies automatically (auto-apply), with status and last change; flags any that are on |
106
107
  | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
107
108
  | `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content |
108
109
  | `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
@@ -120,6 +121,7 @@ The best features come from real workflows. If you're using AdLoop and find your
120
121
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
121
122
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
122
123
  | `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` |
124
+ | `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
125
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
124
126
  | `run_gaql` | Arbitrary GAQL queries for anything else |
125
127
 
@@ -268,12 +270,12 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
268
270
 
269
271
  | Tool | What It Does |
270
272
  |------|-------------|
271
- | `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
273
+ | `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, the location option (presence vs presence or interest), campaign URL options (tracking template, final URL suffix, custom parameters), and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
272
274
  | `draft_shopping_campaign` | Create a Standard Shopping campaign for a linked Merchant Center feed — budget, campaign (PAUSED), product ad group, Shopping ad and an "All products" group in one atomic request. Manual CPC, Maximize clicks or target ROAS. |
273
275
  | `draft_asset_group_assets` | Add and remove Performance Max assets (text, images incl. uploads, YouTube videos) in one change, so replacing an asset never drops a group below Google's minimums |
274
276
  | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group |
275
277
  | `draft_pmax_product_exclusions` | Exclude products from a retail PMax asset group by item id, brand or custom label |
276
- | `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
278
+ | `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, the location option, campaign URL options, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
277
279
  | `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
278
280
  | `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
279
281
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
@@ -306,6 +308,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
306
308
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
307
309
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
308
310
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
311
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
312
+ | `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types, so Google stops changing the account without a preview. Switching auto-apply back on stays in the Google Ads UI |
309
313
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
310
314
  | `enable_entity` | Re-enable a paused entity |
311
315
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -495,8 +499,8 @@ adloop install-rules
495
499
 
496
500
  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
501
 
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.
502
+ - **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.
503
+ - **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
504
 
501
505
  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
506
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.1"
3
+ version = "0.37.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.1"
3
+ version = "0.37.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 = [
@@ -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,202 @@
1
+ """Read and pause Google Ads auto-apply recommendation subscriptions.
2
+
3
+ A subscription makes Google apply every new recommendation of one type
4
+ without asking, so a change reaches the account with no preview. The v25 API
5
+ (verified against the SDK):
6
+
7
+ recommendation_subscription resource_name, type, status (ENABLED | PAUSED),
8
+ create_date_time, modify_date_time
9
+ RecommendationSubscriptionService.MutateRecommendationSubscription
10
+ MutateRecommendationSubscriptionRequest{customer_id, operations[],
11
+ partial_failure, validate_only, response_content_type}
12
+
13
+ A type with no subscription row is not auto-applied. The write side can only
14
+ set a subscription to PAUSED: re-enabling auto-apply is a decision for the
15
+ Google Ads UI, because it lets Google change the account without a preview,
16
+ which is what AdLoop's flow exists to prevent. Pausing reduces automation, so
17
+ a single confirmation is enough. The request has ``validate_only``, so the dry
18
+ run goes through the validate-only client like other Google Ads writes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from datetime import date, datetime, timezone
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from adloop.config import AdLoopConfig
29
+
30
+ OPERATION = "pause_recommendation_subscriptions"
31
+
32
+ # A subscription modified this recently is reported as a change, not only as a
33
+ # standing setting: someone (or something) turned it on or touched it lately.
34
+ RECENT_DAYS = 30
35
+
36
+ _TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
37
+
38
+ _QUERY = """
39
+ SELECT recommendation_subscription.resource_name,
40
+ recommendation_subscription.type,
41
+ recommendation_subscription.status,
42
+ recommendation_subscription.create_date_time,
43
+ recommendation_subscription.modify_date_time
44
+ FROM recommendation_subscription
45
+ """
46
+
47
+
48
+ def _read(config: AdLoopConfig, customer_id: str) -> list[dict]:
49
+ from adloop.ads.gaql import execute_query
50
+
51
+ return [
52
+ {
53
+ "type": row.get("recommendation_subscription.type", ""),
54
+ "status": row.get("recommendation_subscription.status", ""),
55
+ "resource_name": row.get("recommendation_subscription.resource_name", ""),
56
+ "create_date_time": row.get("recommendation_subscription.create_date_time", ""),
57
+ "modify_date_time": row.get("recommendation_subscription.modify_date_time", ""),
58
+ }
59
+ for row in execute_query(config, customer_id, _QUERY)
60
+ ]
61
+
62
+
63
+ def _day(value: str) -> date | None:
64
+ """``2026-10-27 09:14:03`` (account time zone) -> the date, or None."""
65
+ try:
66
+ return date.fromisoformat(str(value or "")[:10])
67
+ except ValueError:
68
+ return None
69
+
70
+
71
+ def get_recommendation_subscriptions(
72
+ config: AdLoopConfig, *, customer_id: str = "", today: date | None = None
73
+ ) -> dict:
74
+ """Read-only: every auto-apply subscription with its status and dates."""
75
+ subscriptions = sorted(_read(config, customer_id), key=lambda s: s["type"])
76
+ enabled = [s for s in subscriptions if s["status"] == "ENABLED"]
77
+ today = today or datetime.now(timezone.utc).date()
78
+
79
+ insights: list[str] = []
80
+ if enabled:
81
+ insights.append(
82
+ f"{len(enabled)} recommendation type(s) auto-apply without a preview: "
83
+ + ", ".join(s["type"] for s in enabled)
84
+ + ". Google changes the account for these on its own. "
85
+ "draft_recommendation_subscriptions pauses them."
86
+ )
87
+ for sub in enabled:
88
+ modified = _day(sub["modify_date_time"])
89
+ if modified and (today - modified).days <= RECENT_DAYS:
90
+ insights.append(
91
+ f"{sub['type']} auto-apply was turned on or changed on "
92
+ f"{sub['modify_date_time']}, within the last {RECENT_DAYS} days. "
93
+ "Confirm that was intended."
94
+ )
95
+ if not enabled:
96
+ insights.append("No recommendation type is set to auto-apply.")
97
+
98
+ return {
99
+ "subscriptions": subscriptions,
100
+ "total_subscriptions": len(subscriptions),
101
+ "enabled_count": len(enabled),
102
+ "insights": insights,
103
+ }
104
+
105
+
106
+ def draft_recommendation_subscriptions(
107
+ config: AdLoopConfig,
108
+ *,
109
+ customer_id: str = "",
110
+ recommendation_types: list[str] | None = None,
111
+ ) -> dict:
112
+ """Draft pausing auto-apply for recommendation types — returns PREVIEW.
113
+
114
+ Pausing is the only change this can make. Each type must have an ENABLED
115
+ subscription; a type with none, or one already paused, is refused by name
116
+ so the preview only lists what the apply will change.
117
+ """
118
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
119
+ from adloop.safety.preview import ChangePlan, store_plan
120
+
121
+ try:
122
+ check_blocked_operation(OPERATION, config.safety)
123
+ except SafetyViolation as e:
124
+ return {"error": str(e)}
125
+
126
+ types: list[str] = []
127
+ errors: list[str] = []
128
+ for raw in recommendation_types or []:
129
+ value = str(raw or "").strip().upper()
130
+ if not value:
131
+ continue
132
+ if not _TYPE_RE.match(value):
133
+ errors.append(f"'{raw}' is not a RecommendationType name")
134
+ elif value not in types:
135
+ types.append(value)
136
+ if not types and not errors:
137
+ errors.append("At least one recommendation type is required")
138
+ if errors:
139
+ return {"error": "Validation failed", "details": errors}
140
+
141
+ current = {s["type"]: s for s in _read(config, customer_id)}
142
+ not_enabled = [t for t in types if current.get(t, {}).get("status") != "ENABLED"]
143
+ if not_enabled:
144
+ return {
145
+ "error": "Validation failed",
146
+ "details": [
147
+ "Not auto-applied in this account (no subscription, or already "
148
+ "paused): " + ", ".join(not_enabled)
149
+ + ". get_recommendation_subscriptions lists the subscriptions."
150
+ ],
151
+ }
152
+
153
+ subscriptions = [
154
+ {
155
+ "type": t,
156
+ "resource_name": current[t]["resource_name"],
157
+ "current_status": "ENABLED",
158
+ "status": "PAUSED",
159
+ "modify_date_time": current[t]["modify_date_time"],
160
+ }
161
+ for t in types
162
+ ]
163
+ plan = ChangePlan(
164
+ operation=OPERATION,
165
+ entity_type="recommendation_subscription",
166
+ entity_id=",".join(types),
167
+ customer_id=customer_id,
168
+ changes={
169
+ "subscriptions": subscriptions,
170
+ "count": len(subscriptions),
171
+ "note": (
172
+ "Pausing stops Google applying these recommendation types on its "
173
+ "own. Recommendations still appear in get_recommendations. "
174
+ "Re-enabling auto-apply is done in the Google Ads UI."
175
+ ),
176
+ },
177
+ )
178
+ store_plan(plan)
179
+ return plan.to_preview()
180
+
181
+
182
+ def _apply_pause_recommendation_subscriptions(
183
+ client: object, cid: str, changes: dict
184
+ ) -> dict:
185
+ """Set each planned subscription to PAUSED in one request."""
186
+ from google.protobuf import field_mask_pb2
187
+
188
+ service = client.get_service("RecommendationSubscriptionService")
189
+ request = client.get_type("MutateRecommendationSubscriptionRequest")
190
+ request.customer_id = cid
191
+ paused_status = client.enums.RecommendationSubscriptionStatusEnum.PAUSED
192
+ for sub in changes["subscriptions"]:
193
+ # Hard-coded: whatever a stored plan says, this path only pauses.
194
+ operation = client.get_type("RecommendationSubscriptionOperation")
195
+ operation.update.resource_name = sub["resource_name"]
196
+ operation.update.status = paused_status
197
+ operation.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=["status"]))
198
+ request.operations.append(operation)
199
+
200
+ response = service.mutate_recommendation_subscription(request=request)
201
+ paused = [result.resource_name for result in response.results]
202
+ return {"paused": paused, "paused_count": len(paused)}