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.
- {adloop-0.35.1 → adloop-0.37.0}/PKG-INFO +9 -5
- {adloop-0.35.1 → adloop-0.37.0}/README.md +8 -4
- {adloop-0.35.1 → adloop-0.37.0}/pyproject.toml +1 -1
- {adloop-0.35.1 → adloop-0.37.0}/pyproject.toml.orig +1 -1
- adloop-0.37.0/src/adloop/ads/campaign_settings.py +222 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/read.py +4 -0
- adloop-0.37.0/src/adloop/ads/recommendation_subscriptions.py +202 -0
- adloop-0.37.0/src/adloop/ads/recommendations.py +210 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/write.py +327 -2
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/cli.py +10 -7
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/read.py +14 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/write.py +62 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/adloop.md +13 -5
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules_install.py +66 -22
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/server.py +223 -4
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/__main__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/assets.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/snippet_headers.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/auth.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/config.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/crossref.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/runtime.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/tracking.py +0 -0
- {adloop-0.35.1 → adloop-0.37.0}/src/adloop/upload_card.html +0 -0
- {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.
|
|
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
|
|
527
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
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
|
|
499
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
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
|
|
|
@@ -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)}
|