adloop 0.16.1__tar.gz → 0.18.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.16.1 → adloop-0.18.0}/PKG-INFO +27 -1
- {adloop-0.16.1 → adloop-0.18.0}/README.md +26 -0
- {adloop-0.16.1 → adloop-0.18.0}/pyproject.toml +1 -1
- {adloop-0.16.1 → adloop-0.18.0}/pyproject.toml.orig +1 -1
- adloop-0.18.0/src/adloop/ads/ai_max.py +555 -0
- adloop-0.18.0/src/adloop/ads/brands.py +354 -0
- adloop-0.18.0/src/adloop/ads/custom_conversion_goals.py +720 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/read.py +14 -2
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/write.py +1080 -17
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/reports.py +21 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/adloop.md +21 -1
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/server.py +665 -7
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/__main__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/auth.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/cli.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/config.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/crossref.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/runtime.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.16.1 → adloop-0.18.0}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.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
|
|
@@ -132,8 +132,23 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
132
132
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
133
133
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
134
134
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
135
|
+
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
136
|
+
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
137
|
+
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
|
|
138
|
+
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
139
|
+
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
140
|
+
| `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` |
|
|
141
|
+
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
135
142
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
136
143
|
|
|
144
|
+
> **Brand lists** — a list is a `SharedSet` of type `BRANDS`; attaching it to a campaign is a `CampaignCriterion.brand_list` (`negative=true` excludes, `false` restricts), **not** a `CampaignSharedSet` like negative keyword lists. `remove_from_brand_list` and `detach_brand_list_from_campaigns` remove for real — `SharedCriterion` has no status field.
|
|
145
|
+
|
|
146
|
+
> **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
|
|
147
|
+
|
|
148
|
+
> **AI Max is the container for Search brand exclusions** — Google rejects a brand list on a plain Search campaign with *"For search advertising channel, brand lists can only be applied to exclusive targeting, broad match campaigns for inclusive targeting or PMax generated campaigns."* `draft_prepare_brand_exclusions` sets the state that makes the exclusion usable **without** handing Google the automations: AI Max on, search term matching off per ad group, text and final-URL automation opted out. `draft_ai_max_settings` is the general form when only parts of that state should change — it refuses a plan that would leave an automation on unnoticed, and asks for a second confirmation when that automation was requested explicitly.
|
|
149
|
+
|
|
150
|
+
> **Custom conversion goals** — a named set of conversion actions that a campaign can be pointed at. `get_custom_conversion_goals` shows the goals with their actions and a campaign's current goal config; `draft_custom_conversion_goal` creates a set, `draft_update_custom_conversion_goal` renames or replaces its actions, `draft_assign_custom_conversion_goal` points a campaign at it and `draft_clear_custom_conversion_goal` puts the campaign back on the account-level goals. Only the goal and the campaign's goal config are touched — conversion actions, bidding and budgets stay as they are.
|
|
151
|
+
|
|
137
152
|
> **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
|
|
138
153
|
|
|
139
154
|
### Cross-Reference Tools (GA4 + Ads Combined)
|
|
@@ -246,6 +261,17 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
246
261
|
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
247
262
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
248
263
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
264
|
+
| `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
|
|
265
|
+
| `add_to_brand_list` | Draft adding brands to an existing brand list |
|
|
266
|
+
| `remove_from_brand_list` | Draft removing brands from a list (SharedCriteria have no status — removal is the only way; asks for a second confirmation) |
|
|
267
|
+
| `attach_brand_list_to_campaigns` | Draft attaching an existing brand list to campaigns as `CampaignCriterion.brand_list` |
|
|
268
|
+
| `detach_brand_list_from_campaigns` | Draft detaching a brand list from campaigns (removes only the criterion, the list stays) |
|
|
269
|
+
| `draft_ai_max_settings` | Draft AI Max controls for a Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, plus text and final-URL asset automation (each `OPTED_IN` / `OPTED_OUT` / `UNCHANGED`) |
|
|
270
|
+
| `draft_prepare_brand_exclusions` | Draft the safe standard state in one step: AI Max on, search term matching off for every non-removed ad group, text and final-URL automation opted out. Touches nothing else |
|
|
271
|
+
| `draft_custom_conversion_goal` | Create a custom conversion goal (a named set of conversion actions) |
|
|
272
|
+
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
273
|
+
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
274
|
+
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
249
275
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
250
276
|
| `enable_entity` | Re-enable a paused entity |
|
|
251
277
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -105,8 +105,23 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
105
105
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
106
106
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
107
107
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
108
|
+
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
109
|
+
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
110
|
+
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
|
|
111
|
+
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
112
|
+
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
113
|
+
| `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` |
|
|
114
|
+
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
108
115
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
109
116
|
|
|
117
|
+
> **Brand lists** — a list is a `SharedSet` of type `BRANDS`; attaching it to a campaign is a `CampaignCriterion.brand_list` (`negative=true` excludes, `false` restricts), **not** a `CampaignSharedSet` like negative keyword lists. `remove_from_brand_list` and `detach_brand_list_from_campaigns` remove for real — `SharedCriterion` has no status field.
|
|
118
|
+
|
|
119
|
+
> **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
|
|
120
|
+
|
|
121
|
+
> **AI Max is the container for Search brand exclusions** — Google rejects a brand list on a plain Search campaign with *"For search advertising channel, brand lists can only be applied to exclusive targeting, broad match campaigns for inclusive targeting or PMax generated campaigns."* `draft_prepare_brand_exclusions` sets the state that makes the exclusion usable **without** handing Google the automations: AI Max on, search term matching off per ad group, text and final-URL automation opted out. `draft_ai_max_settings` is the general form when only parts of that state should change — it refuses a plan that would leave an automation on unnoticed, and asks for a second confirmation when that automation was requested explicitly.
|
|
122
|
+
|
|
123
|
+
> **Custom conversion goals** — a named set of conversion actions that a campaign can be pointed at. `get_custom_conversion_goals` shows the goals with their actions and a campaign's current goal config; `draft_custom_conversion_goal` creates a set, `draft_update_custom_conversion_goal` renames or replaces its actions, `draft_assign_custom_conversion_goal` points a campaign at it and `draft_clear_custom_conversion_goal` puts the campaign back on the account-level goals. Only the goal and the campaign's goal config are touched — conversion actions, bidding and budgets stay as they are.
|
|
124
|
+
|
|
110
125
|
> **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
|
|
111
126
|
|
|
112
127
|
### Cross-Reference Tools (GA4 + Ads Combined)
|
|
@@ -219,6 +234,17 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
219
234
|
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
220
235
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
221
236
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
237
|
+
| `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
|
|
238
|
+
| `add_to_brand_list` | Draft adding brands to an existing brand list |
|
|
239
|
+
| `remove_from_brand_list` | Draft removing brands from a list (SharedCriteria have no status — removal is the only way; asks for a second confirmation) |
|
|
240
|
+
| `attach_brand_list_to_campaigns` | Draft attaching an existing brand list to campaigns as `CampaignCriterion.brand_list` |
|
|
241
|
+
| `detach_brand_list_from_campaigns` | Draft detaching a brand list from campaigns (removes only the criterion, the list stays) |
|
|
242
|
+
| `draft_ai_max_settings` | Draft AI Max controls for a Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, plus text and final-URL asset automation (each `OPTED_IN` / `OPTED_OUT` / `UNCHANGED`) |
|
|
243
|
+
| `draft_prepare_brand_exclusions` | Draft the safe standard state in one step: AI Max on, search term matching off for every non-removed ad group, text and final-URL automation opted out. Touches nothing else |
|
|
244
|
+
| `draft_custom_conversion_goal` | Create a custom conversion goal (a named set of conversion actions) |
|
|
245
|
+
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
246
|
+
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
247
|
+
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
222
248
|
| `pause_entity` | Pause a campaign, ad group, ad, or keyword |
|
|
223
249
|
| `enable_entity` | Re-enable a paused entity |
|
|
224
250
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""AI Max controls — the container that makes brand exclusions usable in Search.
|
|
2
|
+
|
|
3
|
+
Google Ads API v25 fields this module touches (verified against the SDK, not
|
|
4
|
+
guessed):
|
|
5
|
+
|
|
6
|
+
campaign.ai_max_setting.enable_ai_max bool, writable
|
|
7
|
+
campaign.ai_max_setting.bundling_required output only
|
|
8
|
+
campaign.asset_automation_settings[] repeated
|
|
9
|
+
.asset_automation_type AssetAutomationTypeEnum
|
|
10
|
+
.asset_automation_status AssetAutomationStatusEnum (OPTED_IN / OPTED_OUT)
|
|
11
|
+
ad_group.ai_max_ad_group_setting.disable_search_term_matching bool, writable
|
|
12
|
+
|
|
13
|
+
Why this exists: attaching a brand list to a plain Search campaign is rejected
|
|
14
|
+
with
|
|
15
|
+
|
|
16
|
+
For search advertising channel, brand lists can only be applied to
|
|
17
|
+
exclusive targeting, broad match campaigns for inclusive targeting or
|
|
18
|
+
PMax generated campaigns.
|
|
19
|
+
|
|
20
|
+
Search campaigns therefore need AI Max as the container for brand exclusions —
|
|
21
|
+
while everything AI Max would otherwise automate stays switched off: search
|
|
22
|
+
term matching per ad group, and the text/URL asset automations at campaign
|
|
23
|
+
level. ``enable_ai_max`` alone is not a safe state, which is why the apply
|
|
24
|
+
order below matters.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from adloop.config import AdLoopConfig
|
|
33
|
+
|
|
34
|
+
# AssetAutomationTypeEnum members we expose by name in the tool interface.
|
|
35
|
+
# v25 has no plain "FINAL_URL_EXPANSION": the URL knob is modelled as
|
|
36
|
+
# FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION.
|
|
37
|
+
TEXT_ASSET_AUTOMATION = "TEXT_ASSET_AUTOMATION"
|
|
38
|
+
FINAL_URL_EXPANSION = "FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION"
|
|
39
|
+
|
|
40
|
+
AUTOMATION_CHOICES = ("OPTED_IN", "OPTED_OUT", "UNCHANGED")
|
|
41
|
+
|
|
42
|
+
_CAMPAIGN_SELECT = """
|
|
43
|
+
SELECT campaign.id, campaign.name, campaign.status,
|
|
44
|
+
campaign.advertising_channel_type,
|
|
45
|
+
campaign.ai_max_setting.enable_ai_max,
|
|
46
|
+
campaign.ai_max_setting.bundling_required,
|
|
47
|
+
campaign.asset_automation_settings
|
|
48
|
+
FROM campaign
|
|
49
|
+
WHERE {where}
|
|
50
|
+
ORDER BY campaign.name
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_CAMPAIGN_WHERE_ALL = (
|
|
54
|
+
"campaign.status != 'REMOVED' AND campaign.advertising_channel_type = 'SEARCH'"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_AD_GROUP_SELECT = """
|
|
58
|
+
SELECT campaign.id, ad_group.id, ad_group.name, ad_group.status,
|
|
59
|
+
ad_group.ai_max_ad_group_setting.disable_search_term_matching
|
|
60
|
+
FROM ad_group
|
|
61
|
+
WHERE {where}
|
|
62
|
+
ORDER BY campaign.id, ad_group.name
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_AD_GROUP_WHERE_ALL = (
|
|
66
|
+
"ad_group.status != 'REMOVED' AND campaign.status != 'REMOVED' "
|
|
67
|
+
"AND campaign.advertising_channel_type = 'SEARCH'"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_ai_max_settings(
|
|
72
|
+
config: AdLoopConfig,
|
|
73
|
+
*,
|
|
74
|
+
customer_id: str = "",
|
|
75
|
+
campaign_id: str = "",
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Read-only view of the AI Max knobs, per campaign and per ad group.
|
|
78
|
+
|
|
79
|
+
Without ``campaign_id`` every non-removed Search campaign is returned.
|
|
80
|
+
"""
|
|
81
|
+
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
82
|
+
|
|
83
|
+
campaign_id = str(campaign_id or "").strip()
|
|
84
|
+
if campaign_id and not campaign_id.isdigit():
|
|
85
|
+
return {"error": "campaign_id must be a numeric ID"}
|
|
86
|
+
|
|
87
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
88
|
+
client = get_ads_client(config)
|
|
89
|
+
return read_ai_max_state(client, cid, campaign_id=campaign_id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_ai_max_state(client: object, cid: str, *, campaign_id: str = "") -> dict:
|
|
93
|
+
"""Query the campaign settings and ad group switches; return a nested view."""
|
|
94
|
+
service = client.get_service("GoogleAdsService")
|
|
95
|
+
|
|
96
|
+
# The WHERE clause is assembled explicitly — no string surgery on an
|
|
97
|
+
# indented query, which could silently widen the result set.
|
|
98
|
+
if campaign_id:
|
|
99
|
+
if not str(campaign_id).isdigit():
|
|
100
|
+
raise ValueError("campaign_id must be a numeric ID")
|
|
101
|
+
campaign_where = f"campaign.id = {campaign_id}"
|
|
102
|
+
ad_group_where = (
|
|
103
|
+
f"ad_group.status != 'REMOVED' AND campaign.id = {campaign_id}"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
campaign_where = _CAMPAIGN_WHERE_ALL
|
|
107
|
+
ad_group_where = _AD_GROUP_WHERE_ALL
|
|
108
|
+
campaign_query = _CAMPAIGN_SELECT.format(where=campaign_where)
|
|
109
|
+
ad_group_query = _AD_GROUP_SELECT.format(where=ad_group_where)
|
|
110
|
+
|
|
111
|
+
campaign_rows = _search(service, cid, campaign_query)
|
|
112
|
+
ad_group_rows = _search(service, cid, ad_group_query)
|
|
113
|
+
|
|
114
|
+
campaigns = _normalize_campaign_rows(campaign_rows)
|
|
115
|
+
if campaign_id:
|
|
116
|
+
# Never let a row for another campaign through: the draft plans on
|
|
117
|
+
# campaigns[0], so a wider result set would silently plan the wrong one.
|
|
118
|
+
campaigns = [
|
|
119
|
+
campaign
|
|
120
|
+
for campaign in campaigns
|
|
121
|
+
if campaign["campaign_id"] == str(campaign_id)
|
|
122
|
+
]
|
|
123
|
+
by_id = {campaign["campaign_id"]: campaign for campaign in campaigns}
|
|
124
|
+
for row in ad_group_rows:
|
|
125
|
+
campaign_key = str(row.get("campaign.id", ""))
|
|
126
|
+
campaign = by_id.get(campaign_key)
|
|
127
|
+
if campaign is None:
|
|
128
|
+
continue
|
|
129
|
+
campaign["ad_groups"].append(
|
|
130
|
+
{
|
|
131
|
+
"ad_group_id": str(row.get("ad_group.id", "")),
|
|
132
|
+
"ad_group_name": row.get("ad_group.name"),
|
|
133
|
+
"status": row.get("ad_group.status"),
|
|
134
|
+
"disable_search_term_matching": row.get(
|
|
135
|
+
"ad_group.ai_max_ad_group_setting.disable_search_term_matching"
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"campaigns": campaigns,
|
|
142
|
+
"total_campaigns": len(campaigns),
|
|
143
|
+
"total_ad_groups": sum(len(c["ad_groups"]) for c in campaigns),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _search(service: object, cid: str, query: str) -> list[dict]:
|
|
148
|
+
"""Run a GAQL query and flatten each row into {field_path: value}."""
|
|
149
|
+
from adloop.ads.gaql import _extract_field, _parse_select_fields
|
|
150
|
+
|
|
151
|
+
fields = _parse_select_fields(query)
|
|
152
|
+
return [
|
|
153
|
+
{field: _extract_field(row, field) for field in fields}
|
|
154
|
+
for row in service.search(customer_id=cid, query=query)
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize_campaign_rows(rows: list[dict]) -> list[dict]:
|
|
159
|
+
"""Collapse GAQL rows into one entry per campaign.
|
|
160
|
+
|
|
161
|
+
GAQL may return one row per repeated ``asset_automation_settings`` element
|
|
162
|
+
or a single row with parallel lists, depending on how the field is
|
|
163
|
+
expanded. Both shapes are handled instead of assuming one.
|
|
164
|
+
"""
|
|
165
|
+
campaigns: dict[str, dict] = {}
|
|
166
|
+
for row in rows:
|
|
167
|
+
key = str(row.get("campaign.id", ""))
|
|
168
|
+
if not key:
|
|
169
|
+
continue
|
|
170
|
+
entry = campaigns.setdefault(
|
|
171
|
+
key,
|
|
172
|
+
{
|
|
173
|
+
"campaign_id": key,
|
|
174
|
+
"campaign_name": row.get("campaign.name"),
|
|
175
|
+
"status": row.get("campaign.status"),
|
|
176
|
+
"advertising_channel_type": row.get("campaign.advertising_channel_type"),
|
|
177
|
+
"enable_ai_max": row.get("campaign.ai_max_setting.enable_ai_max"),
|
|
178
|
+
"bundling_required": row.get(
|
|
179
|
+
"campaign.ai_max_setting.bundling_required"
|
|
180
|
+
),
|
|
181
|
+
"asset_automation_settings": [],
|
|
182
|
+
"ad_groups": [],
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
for pair in _asset_automation_pairs(row):
|
|
186
|
+
existing = {
|
|
187
|
+
item["asset_automation_type"]: item
|
|
188
|
+
for item in entry["asset_automation_settings"]
|
|
189
|
+
}
|
|
190
|
+
existing[pair["asset_automation_type"]] = pair
|
|
191
|
+
entry["asset_automation_settings"] = list(existing.values())
|
|
192
|
+
return list(campaigns.values())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _asset_automation_pairs(row: dict) -> list[dict]:
|
|
196
|
+
"""Normalize the asset automation settings of one GAQL row.
|
|
197
|
+
|
|
198
|
+
The whole message field is selected (``campaign.asset_automation_settings``,
|
|
199
|
+
not its sub-fields — GAQL answers those with PROHIBITED_FIELD_IN_SELECT_CLAUSE),
|
|
200
|
+
so the common shape is a list of dicts. The parallel-list shape is still
|
|
201
|
+
handled because it is what a sub-field select would have produced.
|
|
202
|
+
"""
|
|
203
|
+
value = row.get("campaign.asset_automation_settings")
|
|
204
|
+
pairs: list[dict] = []
|
|
205
|
+
|
|
206
|
+
if isinstance(value, dict):
|
|
207
|
+
entries: list[object] = [value]
|
|
208
|
+
elif isinstance(value, list):
|
|
209
|
+
entries = value
|
|
210
|
+
else:
|
|
211
|
+
entries = []
|
|
212
|
+
|
|
213
|
+
for entry in entries:
|
|
214
|
+
if isinstance(entry, dict):
|
|
215
|
+
asset_type = entry.get("asset_automation_type")
|
|
216
|
+
if not asset_type:
|
|
217
|
+
continue
|
|
218
|
+
normalized = {"asset_automation_type": asset_type}
|
|
219
|
+
if entry.get("asset_automation_status"):
|
|
220
|
+
normalized["asset_automation_status"] = entry["asset_automation_status"]
|
|
221
|
+
# AUTOMATED_VIDEO_CRAWL carries its configuration in a nested
|
|
222
|
+
# setting instead of a status; keep it so a later write can send it
|
|
223
|
+
# back unchanged.
|
|
224
|
+
if entry.get("automated_video_crawl_setting"):
|
|
225
|
+
normalized["automated_video_crawl_setting"] = entry[
|
|
226
|
+
"automated_video_crawl_setting"
|
|
227
|
+
]
|
|
228
|
+
pairs.append(normalized)
|
|
229
|
+
|
|
230
|
+
if pairs:
|
|
231
|
+
return pairs
|
|
232
|
+
|
|
233
|
+
# Fallback: sub-field selection shape (parallel lists in one row).
|
|
234
|
+
types = row.get("campaign.asset_automation_settings.asset_automation_type")
|
|
235
|
+
statuses = row.get("campaign.asset_automation_settings.asset_automation_status")
|
|
236
|
+
if isinstance(types, list):
|
|
237
|
+
for index, asset_type in enumerate(types):
|
|
238
|
+
status = (
|
|
239
|
+
statuses[index]
|
|
240
|
+
if isinstance(statuses, list) and index < len(statuses)
|
|
241
|
+
else None
|
|
242
|
+
)
|
|
243
|
+
if asset_type:
|
|
244
|
+
pairs.append(
|
|
245
|
+
{"asset_automation_type": asset_type, "asset_automation_status": status}
|
|
246
|
+
)
|
|
247
|
+
elif types:
|
|
248
|
+
pairs.append(
|
|
249
|
+
{"asset_automation_type": types, "asset_automation_status": statuses}
|
|
250
|
+
)
|
|
251
|
+
return pairs
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def merge_asset_automation(
|
|
255
|
+
current: list[dict], updates: dict[str, str]
|
|
256
|
+
) -> list[dict]:
|
|
257
|
+
"""Return the complete settings list with ``updates`` applied per type.
|
|
258
|
+
|
|
259
|
+
Google documents the field only as "the opt-in/out status of each
|
|
260
|
+
AssetAutomationType" — not whether an update replaces or merges the list.
|
|
261
|
+
Sending the complete merged list is correct either way, and preserving the
|
|
262
|
+
untouched types is what keeps this from silently switching other
|
|
263
|
+
automations on or off.
|
|
264
|
+
"""
|
|
265
|
+
merged: dict[str, dict] = {}
|
|
266
|
+
for item in current:
|
|
267
|
+
asset_type = item.get("asset_automation_type")
|
|
268
|
+
if asset_type:
|
|
269
|
+
merged[asset_type] = dict(item)
|
|
270
|
+
for asset_type, status in updates.items():
|
|
271
|
+
if status == "UNCHANGED":
|
|
272
|
+
continue
|
|
273
|
+
entry = merged.setdefault(
|
|
274
|
+
asset_type,
|
|
275
|
+
{"asset_automation_type": asset_type, "asset_automation_status": None},
|
|
276
|
+
)
|
|
277
|
+
entry["asset_automation_status"] = status
|
|
278
|
+
return list(merged.values())
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def plan_targets(
|
|
282
|
+
state: dict,
|
|
283
|
+
*,
|
|
284
|
+
disable_search_term_matching: bool,
|
|
285
|
+
ad_group_ids: list[str] | None,
|
|
286
|
+
include_paused_ad_groups: bool,
|
|
287
|
+
) -> tuple[list[dict], list[str]]:
|
|
288
|
+
"""Resolve which ad groups the plan touches. Returns (targets, warnings)."""
|
|
289
|
+
groups = state.get("ad_groups", [])
|
|
290
|
+
warnings: list[str] = []
|
|
291
|
+
|
|
292
|
+
if ad_group_ids:
|
|
293
|
+
wanted = [str(g) for g in ad_group_ids]
|
|
294
|
+
known = {group["ad_group_id"]: group for group in groups}
|
|
295
|
+
unknown = [g for g in wanted if g not in known]
|
|
296
|
+
if unknown:
|
|
297
|
+
warnings.append(
|
|
298
|
+
"ad_group_ids not found in this campaign (or removed): "
|
|
299
|
+
+ ", ".join(unknown)
|
|
300
|
+
)
|
|
301
|
+
targets = [known[g] for g in wanted if g in known]
|
|
302
|
+
else:
|
|
303
|
+
targets = list(groups)
|
|
304
|
+
if not include_paused_ad_groups:
|
|
305
|
+
skipped = [g for g in targets if g.get("status") != "ENABLED"]
|
|
306
|
+
targets = [g for g in targets if g.get("status") == "ENABLED"]
|
|
307
|
+
if skipped:
|
|
308
|
+
warnings.append(
|
|
309
|
+
f"{len(skipped)} paused ad group(s) skipped "
|
|
310
|
+
"(include_paused_ad_groups=false)"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
for group in targets:
|
|
314
|
+
if group.get("status") == "REMOVED":
|
|
315
|
+
warnings.append(
|
|
316
|
+
f"ad group {group['ad_group_id']} is REMOVED and must never be mutated"
|
|
317
|
+
)
|
|
318
|
+
targets = [g for g in targets if g.get("status") != "REMOVED"]
|
|
319
|
+
return targets, warnings
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def plan_risks(
|
|
323
|
+
campaign: dict,
|
|
324
|
+
*,
|
|
325
|
+
enable_ai_max: bool | None,
|
|
326
|
+
disable_search_term_matching: bool | None,
|
|
327
|
+
automation_updates: dict[str, str],
|
|
328
|
+
targeted_ad_group_ids: set[str],
|
|
329
|
+
) -> dict[str, list[str]]:
|
|
330
|
+
"""Classify the automations still running in the planned AI Max state.
|
|
331
|
+
|
|
332
|
+
This module's rule is that AI Max only ever runs as a container with its
|
|
333
|
+
automations off. A plan can nevertheless end in the automat*on* state in two
|
|
334
|
+
ways, and they must not be treated the same:
|
|
335
|
+
|
|
336
|
+
``implicit``
|
|
337
|
+
The automation keeps running because the caller never mentioned it — an
|
|
338
|
+
unchanged campaign setting or an untouched ad group switch. That is the
|
|
339
|
+
trap this module exists to prevent, and it should be refused.
|
|
340
|
+
``explicit``
|
|
341
|
+
The caller named the automation in the arguments
|
|
342
|
+
(``disable_search_term_matching=false``, ``text_asset_automation=
|
|
343
|
+
OPTED_IN``), or deliberately narrowed the ad group selection. Switching
|
|
344
|
+
to AI Max with its automations is a legitimate Google Ads configuration,
|
|
345
|
+
so it must stay expressible — but it deserves a second confirmation and
|
|
346
|
+
a warning that names what stays automated.
|
|
347
|
+
|
|
348
|
+
Returns ``{"implicit": [...], "explicit": [...]}``, both empty when the
|
|
349
|
+
resulting state has AI Max off or nothing left to automate.
|
|
350
|
+
"""
|
|
351
|
+
resulting_ai_max = (
|
|
352
|
+
bool(campaign.get("enable_ai_max"))
|
|
353
|
+
if enable_ai_max is None
|
|
354
|
+
else bool(enable_ai_max)
|
|
355
|
+
)
|
|
356
|
+
if not resulting_ai_max:
|
|
357
|
+
return {"implicit": [], "explicit": []}
|
|
358
|
+
|
|
359
|
+
inherited: list[str] = []
|
|
360
|
+
chosen: list[str] = []
|
|
361
|
+
|
|
362
|
+
never_mentioned: list[str] = []
|
|
363
|
+
outside_selection: list[str] = []
|
|
364
|
+
switched_on: list[str] = []
|
|
365
|
+
for group in campaign.get("ad_groups", []):
|
|
366
|
+
if group.get("status") == "REMOVED":
|
|
367
|
+
continue
|
|
368
|
+
targeted = group.get("ad_group_id") in targeted_ad_group_ids
|
|
369
|
+
if disable_search_term_matching is None or not targeted:
|
|
370
|
+
after = group.get("disable_search_term_matching") is True
|
|
371
|
+
else:
|
|
372
|
+
after = bool(disable_search_term_matching)
|
|
373
|
+
if after:
|
|
374
|
+
continue
|
|
375
|
+
name = group.get("ad_group_name") or group["ad_group_id"]
|
|
376
|
+
if disable_search_term_matching is None:
|
|
377
|
+
never_mentioned.append(name)
|
|
378
|
+
elif targeted:
|
|
379
|
+
switched_on.append(name)
|
|
380
|
+
else:
|
|
381
|
+
outside_selection.append(name)
|
|
382
|
+
|
|
383
|
+
def _list(names: list[str]) -> str:
|
|
384
|
+
shown = ", ".join(names[:5])
|
|
385
|
+
more = "" if len(names) <= 5 else f" (+{len(names) - 5} more)"
|
|
386
|
+
return f"{shown}{more}"
|
|
387
|
+
|
|
388
|
+
if never_mentioned:
|
|
389
|
+
inherited.append(
|
|
390
|
+
f"search term matching would stay on for {len(never_mentioned)} ad "
|
|
391
|
+
f"group(s): {_list(never_mentioned)} — pass "
|
|
392
|
+
"disable_search_term_matching=true to switch it off"
|
|
393
|
+
)
|
|
394
|
+
if outside_selection:
|
|
395
|
+
# The caller chose the selection (include_paused_ad_groups=false or
|
|
396
|
+
# ad_group_ids), so this is a decision, not an accident — but it still
|
|
397
|
+
# deserves the second confirmation and a warning that names the groups.
|
|
398
|
+
chosen.append(
|
|
399
|
+
f"search term matching stays on for {len(outside_selection)} ad "
|
|
400
|
+
f"group(s) outside the selection: {_list(outside_selection)} — pass "
|
|
401
|
+
"include_paused_ad_groups=true or list them in ad_group_ids to "
|
|
402
|
+
"switch it off as well"
|
|
403
|
+
)
|
|
404
|
+
if switched_on:
|
|
405
|
+
chosen.append(
|
|
406
|
+
f"search term matching stays on for {len(switched_on)} ad group(s) "
|
|
407
|
+
f"— requested with disable_search_term_matching=false: "
|
|
408
|
+
f"{_list(switched_on)}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
for asset_type, status in automation_updates.items():
|
|
412
|
+
if status == "OPTED_IN":
|
|
413
|
+
chosen.append(f"{asset_type} is requested as OPTED_IN")
|
|
414
|
+
|
|
415
|
+
if automation_updates.get(TEXT_ASSET_AUTOMATION, "UNCHANGED") == "UNCHANGED":
|
|
416
|
+
if _current_status(campaign, TEXT_ASSET_AUTOMATION) == "OPTED_IN":
|
|
417
|
+
inherited.append(
|
|
418
|
+
f"{TEXT_ASSET_AUTOMATION} is OPTED_IN in the campaign and left "
|
|
419
|
+
"unchanged — pass text_asset_automation to switch it off"
|
|
420
|
+
)
|
|
421
|
+
if automation_updates.get(FINAL_URL_EXPANSION, "UNCHANGED") == "UNCHANGED":
|
|
422
|
+
if _current_status(campaign, FINAL_URL_EXPANSION) == "OPTED_IN":
|
|
423
|
+
inherited.append(
|
|
424
|
+
f"{FINAL_URL_EXPANSION} is OPTED_IN in the campaign and left "
|
|
425
|
+
"unchanged — pass final_url_expansion to switch it off"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return {"implicit": inherited, "explicit": chosen}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _current_status(campaign: dict, asset_type: str) -> str | None:
|
|
432
|
+
for item in campaign.get("asset_automation_settings") or []:
|
|
433
|
+
if item.get("asset_automation_type") == asset_type:
|
|
434
|
+
return item.get("asset_automation_status")
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# Mutation primitives (all called from the write applier)
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def mutate_ad_group_search_term_matching(
|
|
444
|
+
client: object, cid: str, ad_groups: list[dict], disable: bool
|
|
445
|
+
) -> dict:
|
|
446
|
+
"""Set ``disable_search_term_matching`` on the given ad groups in one request."""
|
|
447
|
+
from google.protobuf import field_mask_pb2
|
|
448
|
+
|
|
449
|
+
service = client.get_service("AdGroupService")
|
|
450
|
+
operations = []
|
|
451
|
+
for group in ad_groups:
|
|
452
|
+
operation = client.get_type("AdGroupOperation")
|
|
453
|
+
update = operation.update
|
|
454
|
+
update.resource_name = service.ad_group_path(cid, group["ad_group_id"])
|
|
455
|
+
update.ai_max_ad_group_setting.disable_search_term_matching = bool(disable)
|
|
456
|
+
operation.update_mask = field_mask_pb2.FieldMask(
|
|
457
|
+
paths=["ai_max_ad_group_setting.disable_search_term_matching"]
|
|
458
|
+
)
|
|
459
|
+
operations.append(operation)
|
|
460
|
+
|
|
461
|
+
request = client.get_type("MutateAdGroupsRequest")
|
|
462
|
+
request.customer_id = cid
|
|
463
|
+
request.operations.extend(operations)
|
|
464
|
+
request.partial_failure = True
|
|
465
|
+
response = service.mutate_ad_groups(request=request)
|
|
466
|
+
return _split_partial_failure(
|
|
467
|
+
client, response, ad_groups, key="ad_group_id", partial_key="failed_ad_groups"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def mutate_campaign_ai_max(client: object, cid: str, changes: dict) -> dict:
|
|
472
|
+
"""Update campaign-level AI Max settings in a single operation."""
|
|
473
|
+
from google.protobuf import field_mask_pb2
|
|
474
|
+
|
|
475
|
+
service = client.get_service("CampaignService")
|
|
476
|
+
operation = client.get_type("CampaignOperation")
|
|
477
|
+
campaign = operation.update
|
|
478
|
+
campaign.resource_name = service.campaign_path(cid, changes["campaign_id"])
|
|
479
|
+
|
|
480
|
+
paths: list[str] = []
|
|
481
|
+
if changes.get("enable_ai_max") is not None:
|
|
482
|
+
campaign.ai_max_setting.enable_ai_max = bool(changes["enable_ai_max"])
|
|
483
|
+
paths.append("ai_max_setting.enable_ai_max")
|
|
484
|
+
|
|
485
|
+
settings = changes.get("asset_automation_settings") or []
|
|
486
|
+
if settings:
|
|
487
|
+
# proto-plus repeated message fields have no ``add()`` and the nested
|
|
488
|
+
# message classes are not reachable by attribute — but ``append`` takes
|
|
489
|
+
# a plain dict and maps enum names and nested messages itself. Settings
|
|
490
|
+
# read back from the account travel through unchanged, so an update that
|
|
491
|
+
# replaces the list cannot silently drop a configuration we never
|
|
492
|
+
# touched (AUTOMATED_VIDEO_CRAWL carries its own nested setting).
|
|
493
|
+
for item in settings:
|
|
494
|
+
entry = {"asset_automation_type": item["asset_automation_type"]}
|
|
495
|
+
if item.get("asset_automation_status"):
|
|
496
|
+
entry["asset_automation_status"] = item["asset_automation_status"]
|
|
497
|
+
if item.get("automated_video_crawl_setting"):
|
|
498
|
+
entry["automated_video_crawl_setting"] = item[
|
|
499
|
+
"automated_video_crawl_setting"
|
|
500
|
+
]
|
|
501
|
+
campaign.asset_automation_settings.append(entry)
|
|
502
|
+
paths.append("asset_automation_settings")
|
|
503
|
+
|
|
504
|
+
if not paths:
|
|
505
|
+
return {"attempted": False, "reason": "no campaign-level change requested"}
|
|
506
|
+
|
|
507
|
+
operation.update_mask = field_mask_pb2.FieldMask(paths=paths)
|
|
508
|
+
response = service.mutate_campaigns(customer_id=cid, operations=[operation])
|
|
509
|
+
return {
|
|
510
|
+
"attempted": True,
|
|
511
|
+
"update_mask": paths,
|
|
512
|
+
"resource_name": response.results[0].resource_name
|
|
513
|
+
if response.results
|
|
514
|
+
else None,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _split_partial_failure(
|
|
519
|
+
client: object, response: object, targets: list[dict], *, key: str, partial_key: str
|
|
520
|
+
) -> dict:
|
|
521
|
+
"""Split a partial-failure mutate response into succeeded/failed entries."""
|
|
522
|
+
from adloop.ads.write import _parse_partial_failure_per_op
|
|
523
|
+
|
|
524
|
+
pf_error = getattr(response, "partial_failure_error", None)
|
|
525
|
+
per_op_errors = _parse_partial_failure_per_op(client, pf_error)
|
|
526
|
+
|
|
527
|
+
succeeded: list[str] = []
|
|
528
|
+
failed: list[dict] = []
|
|
529
|
+
for index, result in enumerate(response.results):
|
|
530
|
+
target = targets[index] if index < len(targets) else {}
|
|
531
|
+
if getattr(result, "resource_name", ""):
|
|
532
|
+
succeeded.append(str(target.get(key, "")))
|
|
533
|
+
else:
|
|
534
|
+
failed.append(
|
|
535
|
+
{
|
|
536
|
+
key: str(target.get(key, "")),
|
|
537
|
+
"operation_index": index,
|
|
538
|
+
"error": per_op_errors.get(
|
|
539
|
+
index, "Unknown error (see partial_failure_message)"
|
|
540
|
+
),
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
out: dict[str, Any] = {
|
|
545
|
+
"succeeded": succeeded,
|
|
546
|
+
"failed": failed,
|
|
547
|
+
"count": len(succeeded),
|
|
548
|
+
}
|
|
549
|
+
if failed:
|
|
550
|
+
out["partial_failure"] = True
|
|
551
|
+
out[partial_key] = failed
|
|
552
|
+
message = getattr(pf_error, "message", "") if pf_error is not None else ""
|
|
553
|
+
if message:
|
|
554
|
+
out["partial_failure_message"] = message
|
|
555
|
+
return out
|