adloop 0.1.0__py3-none-any.whl
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/__init__.py +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
adloop/ads/write.py
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
"""Google Ads write tools — all behind the safety layer.
|
|
2
|
+
|
|
3
|
+
Every write tool returns a preview/plan. Nothing executes until
|
|
4
|
+
``confirm_and_apply`` is called with the plan ID.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from adloop.config import AdLoopConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Draft tools — validate inputs, create a ChangePlan, return preview
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def draft_responsive_search_ad(
|
|
21
|
+
config: AdLoopConfig,
|
|
22
|
+
*,
|
|
23
|
+
customer_id: str = "",
|
|
24
|
+
ad_group_id: str = "",
|
|
25
|
+
headlines: list[str] | None = None,
|
|
26
|
+
descriptions: list[str] | None = None,
|
|
27
|
+
final_url: str = "",
|
|
28
|
+
path1: str = "",
|
|
29
|
+
path2: str = "",
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Draft a Responsive Search Ad — returns preview, does NOT execute."""
|
|
32
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
33
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
check_blocked_operation("create_responsive_search_ad", config.safety)
|
|
37
|
+
except SafetyViolation as e:
|
|
38
|
+
return {"error": str(e)}
|
|
39
|
+
|
|
40
|
+
headlines = headlines or []
|
|
41
|
+
descriptions = descriptions or []
|
|
42
|
+
|
|
43
|
+
errors = _validate_rsa(ad_group_id, headlines, descriptions, final_url)
|
|
44
|
+
if errors:
|
|
45
|
+
return {"error": "Validation failed", "details": errors}
|
|
46
|
+
|
|
47
|
+
warnings = []
|
|
48
|
+
if len(headlines) < 8:
|
|
49
|
+
warnings.append(
|
|
50
|
+
f"Only {len(headlines)} headlines provided. Google recommends 8-15 "
|
|
51
|
+
"diverse headlines for optimal RSA performance."
|
|
52
|
+
)
|
|
53
|
+
if len(descriptions) < 3:
|
|
54
|
+
warnings.append(
|
|
55
|
+
f"Only {len(descriptions)} descriptions provided. Google recommends "
|
|
56
|
+
"3-4 descriptions for optimal RSA performance."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
plan = ChangePlan(
|
|
60
|
+
operation="create_responsive_search_ad",
|
|
61
|
+
entity_type="ad",
|
|
62
|
+
customer_id=customer_id,
|
|
63
|
+
changes={
|
|
64
|
+
"ad_group_id": ad_group_id,
|
|
65
|
+
"headlines": headlines,
|
|
66
|
+
"descriptions": descriptions,
|
|
67
|
+
"final_url": final_url,
|
|
68
|
+
"path1": path1,
|
|
69
|
+
"path2": path2,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
store_plan(plan)
|
|
73
|
+
preview = plan.to_preview()
|
|
74
|
+
if warnings:
|
|
75
|
+
preview["warnings"] = warnings
|
|
76
|
+
return preview
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def draft_keywords(
|
|
80
|
+
config: AdLoopConfig,
|
|
81
|
+
*,
|
|
82
|
+
customer_id: str = "",
|
|
83
|
+
ad_group_id: str = "",
|
|
84
|
+
keywords: list[dict] | None = None,
|
|
85
|
+
) -> dict:
|
|
86
|
+
"""Draft keyword additions with match types — returns preview."""
|
|
87
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
88
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
check_blocked_operation("add_keywords", config.safety)
|
|
92
|
+
except SafetyViolation as e:
|
|
93
|
+
return {"error": str(e)}
|
|
94
|
+
|
|
95
|
+
keywords = keywords or []
|
|
96
|
+
|
|
97
|
+
errors = _validate_keywords(ad_group_id, keywords)
|
|
98
|
+
if errors:
|
|
99
|
+
return {"error": "Validation failed", "details": errors}
|
|
100
|
+
|
|
101
|
+
warnings = _check_broad_match_safety(config, customer_id, ad_group_id, keywords)
|
|
102
|
+
|
|
103
|
+
plan = ChangePlan(
|
|
104
|
+
operation="add_keywords",
|
|
105
|
+
entity_type="keyword",
|
|
106
|
+
customer_id=customer_id,
|
|
107
|
+
changes={
|
|
108
|
+
"ad_group_id": ad_group_id,
|
|
109
|
+
"keywords": keywords,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
store_plan(plan)
|
|
113
|
+
preview = plan.to_preview()
|
|
114
|
+
if warnings:
|
|
115
|
+
preview["warnings"] = warnings
|
|
116
|
+
return preview
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def add_negative_keywords(
|
|
120
|
+
config: AdLoopConfig,
|
|
121
|
+
*,
|
|
122
|
+
customer_id: str = "",
|
|
123
|
+
campaign_id: str = "",
|
|
124
|
+
keywords: list[str] | None = None,
|
|
125
|
+
match_type: str = "EXACT",
|
|
126
|
+
) -> dict:
|
|
127
|
+
"""Draft negative keyword additions — returns preview."""
|
|
128
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
129
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
check_blocked_operation("add_negative_keywords", config.safety)
|
|
133
|
+
except SafetyViolation as e:
|
|
134
|
+
return {"error": str(e)}
|
|
135
|
+
|
|
136
|
+
keywords = keywords or []
|
|
137
|
+
match_type = match_type.upper()
|
|
138
|
+
|
|
139
|
+
errors = []
|
|
140
|
+
if not campaign_id:
|
|
141
|
+
errors.append("campaign_id is required")
|
|
142
|
+
if not keywords:
|
|
143
|
+
errors.append("At least one keyword is required")
|
|
144
|
+
if match_type not in _VALID_MATCH_TYPES:
|
|
145
|
+
errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
|
|
146
|
+
if errors:
|
|
147
|
+
return {"error": "Validation failed", "details": errors}
|
|
148
|
+
|
|
149
|
+
plan = ChangePlan(
|
|
150
|
+
operation="add_negative_keywords",
|
|
151
|
+
entity_type="negative_keyword",
|
|
152
|
+
entity_id=campaign_id,
|
|
153
|
+
customer_id=customer_id,
|
|
154
|
+
changes={
|
|
155
|
+
"campaign_id": campaign_id,
|
|
156
|
+
"keywords": keywords,
|
|
157
|
+
"match_type": match_type,
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
store_plan(plan)
|
|
161
|
+
return plan.to_preview()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pause_entity(
|
|
165
|
+
config: AdLoopConfig,
|
|
166
|
+
*,
|
|
167
|
+
customer_id: str = "",
|
|
168
|
+
entity_type: str = "",
|
|
169
|
+
entity_id: str = "",
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Draft pausing a campaign/ad group/ad/keyword — returns preview."""
|
|
172
|
+
return _draft_status_change(
|
|
173
|
+
config, "pause_entity", customer_id, entity_type, entity_id, "PAUSED"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def enable_entity(
|
|
178
|
+
config: AdLoopConfig,
|
|
179
|
+
*,
|
|
180
|
+
customer_id: str = "",
|
|
181
|
+
entity_type: str = "",
|
|
182
|
+
entity_id: str = "",
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""Draft enabling a paused entity — returns preview."""
|
|
185
|
+
return _draft_status_change(
|
|
186
|
+
config, "enable_entity", customer_id, entity_type, entity_id, "ENABLED"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def remove_entity(
|
|
191
|
+
config: AdLoopConfig,
|
|
192
|
+
*,
|
|
193
|
+
customer_id: str = "",
|
|
194
|
+
entity_type: str = "",
|
|
195
|
+
entity_id: str = "",
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""Draft removing an entity (keyword, negative_keyword, ad, ad_group, campaign).
|
|
198
|
+
|
|
199
|
+
This is a DESTRUCTIVE operation — removed entities cannot be re-enabled.
|
|
200
|
+
For keywords and negative keywords, this fully deletes the criterion.
|
|
201
|
+
Returns a preview; call confirm_and_apply to execute.
|
|
202
|
+
"""
|
|
203
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
204
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
check_blocked_operation("remove_entity", config.safety)
|
|
208
|
+
except SafetyViolation as e:
|
|
209
|
+
return {"error": str(e)}
|
|
210
|
+
|
|
211
|
+
errors = []
|
|
212
|
+
if entity_type not in _REMOVABLE_ENTITY_TYPES:
|
|
213
|
+
errors.append(
|
|
214
|
+
f"entity_type must be one of {_REMOVABLE_ENTITY_TYPES}, "
|
|
215
|
+
f"got '{entity_type}'"
|
|
216
|
+
)
|
|
217
|
+
if not entity_id:
|
|
218
|
+
errors.append("entity_id is required")
|
|
219
|
+
if errors:
|
|
220
|
+
return {"error": "Validation failed", "details": errors}
|
|
221
|
+
|
|
222
|
+
plan = ChangePlan(
|
|
223
|
+
operation="remove_entity",
|
|
224
|
+
entity_type=entity_type,
|
|
225
|
+
entity_id=entity_id,
|
|
226
|
+
customer_id=customer_id,
|
|
227
|
+
changes={"action": "REMOVE"},
|
|
228
|
+
)
|
|
229
|
+
store_plan(plan)
|
|
230
|
+
return plan.to_preview()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def draft_campaign(
|
|
234
|
+
config: AdLoopConfig,
|
|
235
|
+
*,
|
|
236
|
+
customer_id: str = "",
|
|
237
|
+
campaign_name: str = "",
|
|
238
|
+
daily_budget: float = 0,
|
|
239
|
+
bidding_strategy: str = "",
|
|
240
|
+
target_cpa: float = 0,
|
|
241
|
+
target_roas: float = 0,
|
|
242
|
+
channel_type: str = "SEARCH",
|
|
243
|
+
ad_group_name: str = "",
|
|
244
|
+
keywords: list[dict] | None = None,
|
|
245
|
+
) -> dict:
|
|
246
|
+
"""Draft a full campaign structure — returns preview, does NOT execute.
|
|
247
|
+
|
|
248
|
+
Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords.
|
|
249
|
+
Ads are NOT included — use draft_responsive_search_ad separately after the
|
|
250
|
+
campaign exists.
|
|
251
|
+
"""
|
|
252
|
+
from adloop.safety.guards import (
|
|
253
|
+
SafetyViolation,
|
|
254
|
+
check_blocked_operation,
|
|
255
|
+
check_budget_cap,
|
|
256
|
+
)
|
|
257
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
check_blocked_operation("create_campaign", config.safety)
|
|
261
|
+
except SafetyViolation as e:
|
|
262
|
+
return {"error": str(e)}
|
|
263
|
+
|
|
264
|
+
errors, warnings = _validate_campaign(
|
|
265
|
+
config,
|
|
266
|
+
campaign_name=campaign_name,
|
|
267
|
+
daily_budget=daily_budget,
|
|
268
|
+
bidding_strategy=bidding_strategy,
|
|
269
|
+
target_cpa=target_cpa,
|
|
270
|
+
target_roas=target_roas,
|
|
271
|
+
channel_type=channel_type,
|
|
272
|
+
keywords=keywords,
|
|
273
|
+
)
|
|
274
|
+
if errors:
|
|
275
|
+
return {"error": "Validation failed", "details": errors}
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
check_budget_cap(daily_budget, config.safety)
|
|
279
|
+
except SafetyViolation as e:
|
|
280
|
+
return {"error": str(e)}
|
|
281
|
+
|
|
282
|
+
plan = ChangePlan(
|
|
283
|
+
operation="create_campaign",
|
|
284
|
+
entity_type="campaign",
|
|
285
|
+
customer_id=customer_id,
|
|
286
|
+
changes={
|
|
287
|
+
"campaign_name": campaign_name,
|
|
288
|
+
"daily_budget": daily_budget,
|
|
289
|
+
"bidding_strategy": bidding_strategy.upper(),
|
|
290
|
+
"target_cpa": target_cpa if target_cpa else None,
|
|
291
|
+
"target_roas": target_roas if target_roas else None,
|
|
292
|
+
"channel_type": channel_type.upper(),
|
|
293
|
+
"ad_group_name": ad_group_name or campaign_name,
|
|
294
|
+
"keywords": keywords,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
store_plan(plan)
|
|
298
|
+
preview = plan.to_preview()
|
|
299
|
+
if warnings:
|
|
300
|
+
preview["warnings"] = warnings
|
|
301
|
+
return preview
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# confirm_and_apply — the only function that actually mutates Google Ads
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def confirm_and_apply(
|
|
310
|
+
config: AdLoopConfig,
|
|
311
|
+
*,
|
|
312
|
+
plan_id: str = "",
|
|
313
|
+
dry_run: bool = True,
|
|
314
|
+
) -> dict:
|
|
315
|
+
"""Execute a previously previewed change.
|
|
316
|
+
|
|
317
|
+
Defaults to dry_run=True. The caller must explicitly pass dry_run=False
|
|
318
|
+
to make real changes.
|
|
319
|
+
"""
|
|
320
|
+
from adloop.safety.audit import log_mutation
|
|
321
|
+
from adloop.safety.preview import get_plan, remove_plan
|
|
322
|
+
|
|
323
|
+
plan = get_plan(plan_id)
|
|
324
|
+
if plan is None:
|
|
325
|
+
return {
|
|
326
|
+
"error": f"No pending plan found with id '{plan_id}'. "
|
|
327
|
+
"Plans expire when the MCP server restarts.",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if config.safety.require_dry_run:
|
|
331
|
+
dry_run = True
|
|
332
|
+
|
|
333
|
+
if dry_run:
|
|
334
|
+
log_mutation(
|
|
335
|
+
config.safety.log_file,
|
|
336
|
+
operation=plan.operation,
|
|
337
|
+
customer_id=plan.customer_id,
|
|
338
|
+
entity_type=plan.entity_type,
|
|
339
|
+
entity_id=plan.entity_id,
|
|
340
|
+
changes=plan.changes,
|
|
341
|
+
dry_run=True,
|
|
342
|
+
result="dry_run_success",
|
|
343
|
+
)
|
|
344
|
+
return {
|
|
345
|
+
"status": "DRY_RUN_SUCCESS",
|
|
346
|
+
"plan_id": plan.plan_id,
|
|
347
|
+
"operation": plan.operation,
|
|
348
|
+
"changes": plan.changes,
|
|
349
|
+
"message": (
|
|
350
|
+
"Dry run completed — no changes were made to your Google Ads account. "
|
|
351
|
+
"To apply for real, call confirm_and_apply again with dry_run=false."
|
|
352
|
+
),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
result = _execute_plan(config, plan)
|
|
357
|
+
except Exception as e:
|
|
358
|
+
log_mutation(
|
|
359
|
+
config.safety.log_file,
|
|
360
|
+
operation=plan.operation,
|
|
361
|
+
customer_id=plan.customer_id,
|
|
362
|
+
entity_type=plan.entity_type,
|
|
363
|
+
entity_id=plan.entity_id,
|
|
364
|
+
changes=plan.changes,
|
|
365
|
+
dry_run=False,
|
|
366
|
+
result="error",
|
|
367
|
+
error=str(e),
|
|
368
|
+
)
|
|
369
|
+
return {"error": str(e), "plan_id": plan.plan_id}
|
|
370
|
+
|
|
371
|
+
log_mutation(
|
|
372
|
+
config.safety.log_file,
|
|
373
|
+
operation=plan.operation,
|
|
374
|
+
customer_id=plan.customer_id,
|
|
375
|
+
entity_type=plan.entity_type,
|
|
376
|
+
entity_id=plan.entity_id,
|
|
377
|
+
changes=plan.changes,
|
|
378
|
+
dry_run=False,
|
|
379
|
+
result="success",
|
|
380
|
+
)
|
|
381
|
+
remove_plan(plan.plan_id)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
"status": "APPLIED",
|
|
385
|
+
"plan_id": plan.plan_id,
|
|
386
|
+
"operation": plan.operation,
|
|
387
|
+
"result": result,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# Internal validation helpers
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
_VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
|
|
396
|
+
_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"}
|
|
397
|
+
_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {"negative_keyword"}
|
|
398
|
+
|
|
399
|
+
_SMART_BIDDING_STRATEGIES = {
|
|
400
|
+
"MAXIMIZE_CONVERSIONS",
|
|
401
|
+
"MAXIMIZE_CONVERSION_VALUE",
|
|
402
|
+
"TARGET_CPA",
|
|
403
|
+
"TARGET_ROAS",
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _check_broad_match_safety(
|
|
408
|
+
config: AdLoopConfig,
|
|
409
|
+
customer_id: str,
|
|
410
|
+
ad_group_id: str,
|
|
411
|
+
keywords: list[dict],
|
|
412
|
+
) -> list[str]:
|
|
413
|
+
"""Warn if BROAD match keywords are being added to a non-Smart Bidding campaign."""
|
|
414
|
+
has_broad = any(
|
|
415
|
+
kw.get("match_type", "").upper() == "BROAD" for kw in keywords
|
|
416
|
+
)
|
|
417
|
+
if not has_broad:
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
from adloop.ads.gaql import execute_query
|
|
422
|
+
|
|
423
|
+
query = f"""
|
|
424
|
+
SELECT campaign.bidding_strategy_type, campaign.name
|
|
425
|
+
FROM ad_group
|
|
426
|
+
WHERE ad_group.id = {ad_group_id}
|
|
427
|
+
"""
|
|
428
|
+
rows = execute_query(config, customer_id, query)
|
|
429
|
+
if not rows:
|
|
430
|
+
return []
|
|
431
|
+
|
|
432
|
+
bidding = rows[0].get("campaign.bidding_strategy_type", "")
|
|
433
|
+
campaign_name = rows[0].get("campaign.name", "")
|
|
434
|
+
|
|
435
|
+
if bidding not in _SMART_BIDDING_STRATEGIES:
|
|
436
|
+
return [
|
|
437
|
+
f"DANGEROUS: Adding BROAD match keywords to campaign "
|
|
438
|
+
f"'{campaign_name}' which uses {bidding} bidding. "
|
|
439
|
+
f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) "
|
|
440
|
+
f"leads to irrelevant matches and wasted budget. "
|
|
441
|
+
f"Use PHRASE or EXACT match instead, or switch the campaign "
|
|
442
|
+
f"to Smart Bidding first."
|
|
443
|
+
]
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
return []
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _validate_rsa(
|
|
451
|
+
ad_group_id: str,
|
|
452
|
+
headlines: list[str],
|
|
453
|
+
descriptions: list[str],
|
|
454
|
+
final_url: str,
|
|
455
|
+
) -> list[str]:
|
|
456
|
+
errors = []
|
|
457
|
+
if not ad_group_id:
|
|
458
|
+
errors.append("ad_group_id is required")
|
|
459
|
+
if not final_url:
|
|
460
|
+
errors.append("final_url is required")
|
|
461
|
+
if len(headlines) < 3:
|
|
462
|
+
errors.append(f"Need at least 3 headlines, got {len(headlines)}")
|
|
463
|
+
if len(headlines) > 15:
|
|
464
|
+
errors.append(f"Maximum 15 headlines, got {len(headlines)}")
|
|
465
|
+
if len(descriptions) < 2:
|
|
466
|
+
errors.append(f"Need at least 2 descriptions, got {len(descriptions)}")
|
|
467
|
+
if len(descriptions) > 4:
|
|
468
|
+
errors.append(f"Maximum 4 descriptions, got {len(descriptions)}")
|
|
469
|
+
for i, h in enumerate(headlines):
|
|
470
|
+
if len(h) > 30:
|
|
471
|
+
errors.append(f"Headline {i + 1} exceeds 30 chars ({len(h)}): '{h}'")
|
|
472
|
+
for i, d in enumerate(descriptions):
|
|
473
|
+
if len(d) > 90:
|
|
474
|
+
errors.append(f"Description {i + 1} exceeds 90 chars ({len(d)}): '{d}'")
|
|
475
|
+
return errors
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
_VALID_BIDDING_STRATEGIES = {
|
|
479
|
+
"MAXIMIZE_CONVERSIONS",
|
|
480
|
+
"MAXIMIZE_CONVERSION_VALUE",
|
|
481
|
+
"TARGET_CPA",
|
|
482
|
+
"TARGET_ROAS",
|
|
483
|
+
"TARGET_SPEND",
|
|
484
|
+
"MANUAL_CPC",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _validate_campaign(
|
|
491
|
+
config: AdLoopConfig,
|
|
492
|
+
*,
|
|
493
|
+
campaign_name: str,
|
|
494
|
+
daily_budget: float,
|
|
495
|
+
bidding_strategy: str,
|
|
496
|
+
target_cpa: float,
|
|
497
|
+
target_roas: float,
|
|
498
|
+
channel_type: str,
|
|
499
|
+
keywords: list[dict] | None,
|
|
500
|
+
) -> tuple[list[str], list[str]]:
|
|
501
|
+
"""Validate campaign draft inputs. Returns (errors, warnings)."""
|
|
502
|
+
errors = []
|
|
503
|
+
warnings = []
|
|
504
|
+
|
|
505
|
+
if not campaign_name or not campaign_name.strip():
|
|
506
|
+
errors.append("campaign_name is required")
|
|
507
|
+
if daily_budget <= 0:
|
|
508
|
+
errors.append("daily_budget must be greater than 0")
|
|
509
|
+
|
|
510
|
+
bs = bidding_strategy.upper()
|
|
511
|
+
if bs not in _VALID_BIDDING_STRATEGIES:
|
|
512
|
+
errors.append(
|
|
513
|
+
f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, "
|
|
514
|
+
f"got '{bidding_strategy}'"
|
|
515
|
+
)
|
|
516
|
+
if bs == "TARGET_CPA" and not target_cpa:
|
|
517
|
+
errors.append("target_cpa is required when bidding_strategy is TARGET_CPA")
|
|
518
|
+
if bs == "TARGET_ROAS" and not target_roas:
|
|
519
|
+
errors.append("target_roas is required when bidding_strategy is TARGET_ROAS")
|
|
520
|
+
|
|
521
|
+
ct = channel_type.upper()
|
|
522
|
+
if ct not in _VALID_CHANNEL_TYPES:
|
|
523
|
+
errors.append(
|
|
524
|
+
f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, "
|
|
525
|
+
f"got '{channel_type}'"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if keywords:
|
|
529
|
+
has_broad = any(
|
|
530
|
+
kw.get("match_type", "").upper() == "BROAD" for kw in keywords
|
|
531
|
+
)
|
|
532
|
+
if has_broad and bs not in _SMART_BIDDING_STRATEGIES:
|
|
533
|
+
errors.append(
|
|
534
|
+
f"BROAD match keywords require Smart Bidding "
|
|
535
|
+
f"(tCPA/tROAS/Maximize Conversions). "
|
|
536
|
+
f"'{bidding_strategy}' is not a Smart Bidding strategy. "
|
|
537
|
+
f"Use PHRASE or EXACT match instead."
|
|
538
|
+
)
|
|
539
|
+
for i, kw in enumerate(keywords):
|
|
540
|
+
if not kw.get("text"):
|
|
541
|
+
errors.append(f"Keyword {i + 1} has no text")
|
|
542
|
+
mt = kw.get("match_type", "").upper()
|
|
543
|
+
if mt not in _VALID_MATCH_TYPES:
|
|
544
|
+
errors.append(
|
|
545
|
+
f"Keyword {i + 1} has invalid match_type '{mt}' "
|
|
546
|
+
"(must be EXACT, PHRASE, or BROAD)"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if target_cpa > 0 and daily_budget < 5 * target_cpa:
|
|
550
|
+
warnings.append(
|
|
551
|
+
f"Daily budget €{daily_budget:.2f} is less than 5x target CPA "
|
|
552
|
+
f"€{target_cpa:.2f}. Google recommends at least 5x target CPA "
|
|
553
|
+
f"(€{5 * target_cpa:.2f}/day) for sufficient learning data."
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if bs == "MANUAL_CPC":
|
|
557
|
+
warnings.append(
|
|
558
|
+
"MANUAL_CPC bidding requires constant monitoring. Consider using "
|
|
559
|
+
"MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization."
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return errors, warnings
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]:
|
|
566
|
+
errors = []
|
|
567
|
+
if not ad_group_id:
|
|
568
|
+
errors.append("ad_group_id is required")
|
|
569
|
+
if not keywords:
|
|
570
|
+
errors.append("At least one keyword is required")
|
|
571
|
+
for i, kw in enumerate(keywords):
|
|
572
|
+
if not kw.get("text"):
|
|
573
|
+
errors.append(f"Keyword {i + 1} has no text")
|
|
574
|
+
mt = kw.get("match_type", "").upper()
|
|
575
|
+
if mt not in _VALID_MATCH_TYPES:
|
|
576
|
+
errors.append(
|
|
577
|
+
f"Keyword {i + 1} has invalid match_type '{mt}' "
|
|
578
|
+
"(must be EXACT, PHRASE, or BROAD)"
|
|
579
|
+
)
|
|
580
|
+
return errors
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _draft_status_change(
|
|
584
|
+
config: AdLoopConfig,
|
|
585
|
+
operation: str,
|
|
586
|
+
customer_id: str,
|
|
587
|
+
entity_type: str,
|
|
588
|
+
entity_id: str,
|
|
589
|
+
target_status: str,
|
|
590
|
+
) -> dict:
|
|
591
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
592
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
check_blocked_operation(operation, config.safety)
|
|
596
|
+
except SafetyViolation as e:
|
|
597
|
+
return {"error": str(e)}
|
|
598
|
+
|
|
599
|
+
errors = []
|
|
600
|
+
if entity_type not in _VALID_ENTITY_TYPES:
|
|
601
|
+
errors.append(
|
|
602
|
+
f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'"
|
|
603
|
+
)
|
|
604
|
+
if not entity_id:
|
|
605
|
+
errors.append("entity_id is required")
|
|
606
|
+
if errors:
|
|
607
|
+
return {"error": "Validation failed", "details": errors}
|
|
608
|
+
|
|
609
|
+
plan = ChangePlan(
|
|
610
|
+
operation=operation,
|
|
611
|
+
entity_type=entity_type,
|
|
612
|
+
entity_id=entity_id,
|
|
613
|
+
customer_id=customer_id,
|
|
614
|
+
changes={"target_status": target_status},
|
|
615
|
+
)
|
|
616
|
+
store_plan(plan)
|
|
617
|
+
return plan.to_preview()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# Execution — actual Google Ads API mutate calls
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
626
|
+
"""Dispatch to the right Google Ads mutate call based on plan.operation."""
|
|
627
|
+
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
628
|
+
|
|
629
|
+
client = get_ads_client(config)
|
|
630
|
+
cid = normalize_customer_id(plan.customer_id)
|
|
631
|
+
|
|
632
|
+
dispatch = {
|
|
633
|
+
"create_campaign": _apply_create_campaign,
|
|
634
|
+
"create_responsive_search_ad": _apply_create_rsa,
|
|
635
|
+
"add_keywords": _apply_add_keywords,
|
|
636
|
+
"add_negative_keywords": _apply_add_negative_keywords,
|
|
637
|
+
"pause_entity": _apply_status_change,
|
|
638
|
+
"enable_entity": _apply_status_change,
|
|
639
|
+
"remove_entity": _apply_remove,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
handler = dispatch.get(plan.operation)
|
|
643
|
+
if handler is None:
|
|
644
|
+
raise ValueError(f"Unknown operation: {plan.operation}")
|
|
645
|
+
|
|
646
|
+
if plan.operation in ("pause_entity", "enable_entity"):
|
|
647
|
+
return handler(
|
|
648
|
+
client,
|
|
649
|
+
cid,
|
|
650
|
+
plan.entity_type,
|
|
651
|
+
plan.entity_id,
|
|
652
|
+
plan.changes["target_status"],
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
if plan.operation == "remove_entity":
|
|
656
|
+
return handler(client, cid, plan.entity_type, plan.entity_id)
|
|
657
|
+
|
|
658
|
+
return handler(client, cid, plan.changes)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
|
|
662
|
+
"""Create campaign + budget + ad group + optional keywords atomically."""
|
|
663
|
+
service = client.get_service("GoogleAdsService")
|
|
664
|
+
campaign_service = client.get_service("CampaignService")
|
|
665
|
+
budget_service = client.get_service("CampaignBudgetService")
|
|
666
|
+
ad_group_service = client.get_service("AdGroupService")
|
|
667
|
+
|
|
668
|
+
operations = []
|
|
669
|
+
|
|
670
|
+
# 1. CampaignBudget (temp ID: -1)
|
|
671
|
+
budget_op = client.get_type("MutateOperation")
|
|
672
|
+
budget = budget_op.campaign_budget_operation.create
|
|
673
|
+
budget.resource_name = budget_service.campaign_budget_path(cid, "-1")
|
|
674
|
+
budget.name = f"Budget - {changes['campaign_name']}"
|
|
675
|
+
budget.amount_micros = int(changes["daily_budget"] * 1_000_000)
|
|
676
|
+
budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD
|
|
677
|
+
budget.explicitly_shared = False
|
|
678
|
+
operations.append(budget_op)
|
|
679
|
+
|
|
680
|
+
# 2. Campaign (temp ID: -2, references budget -1)
|
|
681
|
+
campaign_op = client.get_type("MutateOperation")
|
|
682
|
+
campaign = campaign_op.campaign_operation.create
|
|
683
|
+
campaign.resource_name = campaign_service.campaign_path(cid, "-2")
|
|
684
|
+
campaign.name = changes["campaign_name"]
|
|
685
|
+
campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1")
|
|
686
|
+
campaign.status = client.enums.CampaignStatusEnum.PAUSED
|
|
687
|
+
|
|
688
|
+
channel = changes.get("channel_type", "SEARCH")
|
|
689
|
+
campaign.advertising_channel_type = getattr(
|
|
690
|
+
client.enums.AdvertisingChannelTypeEnum, channel
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
bs = changes["bidding_strategy"]
|
|
694
|
+
if bs == "MAXIMIZE_CONVERSIONS":
|
|
695
|
+
campaign.maximize_conversions.target_cpa_micros = 0
|
|
696
|
+
if changes.get("target_cpa"):
|
|
697
|
+
campaign.maximize_conversions.target_cpa_micros = int(
|
|
698
|
+
changes["target_cpa"] * 1_000_000
|
|
699
|
+
)
|
|
700
|
+
elif bs == "TARGET_CPA":
|
|
701
|
+
campaign.maximize_conversions.target_cpa_micros = int(
|
|
702
|
+
changes["target_cpa"] * 1_000_000
|
|
703
|
+
)
|
|
704
|
+
elif bs == "MAXIMIZE_CONVERSION_VALUE":
|
|
705
|
+
campaign.maximize_conversion_value.target_roas = 0
|
|
706
|
+
if changes.get("target_roas"):
|
|
707
|
+
campaign.maximize_conversion_value.target_roas = changes["target_roas"]
|
|
708
|
+
elif bs == "TARGET_ROAS":
|
|
709
|
+
campaign.maximize_conversion_value.target_roas = changes["target_roas"]
|
|
710
|
+
elif bs == "TARGET_SPEND":
|
|
711
|
+
campaign.target_spend.target_spend_micros = 0
|
|
712
|
+
elif bs == "MANUAL_CPC":
|
|
713
|
+
campaign.manual_cpc.enhanced_cpc_enabled = False
|
|
714
|
+
|
|
715
|
+
campaign.network_settings.target_google_search = True
|
|
716
|
+
campaign.network_settings.target_search_network = False
|
|
717
|
+
campaign.network_settings.target_content_network = False
|
|
718
|
+
|
|
719
|
+
operations.append(campaign_op)
|
|
720
|
+
|
|
721
|
+
# 3. AdGroup (temp ID: -3, references campaign -2)
|
|
722
|
+
ag_op = client.get_type("MutateOperation")
|
|
723
|
+
ad_group = ag_op.ad_group_operation.create
|
|
724
|
+
ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3")
|
|
725
|
+
ad_group.name = changes.get("ad_group_name", changes["campaign_name"])
|
|
726
|
+
ad_group.campaign = campaign_service.campaign_path(cid, "-2")
|
|
727
|
+
ad_group.status = client.enums.AdGroupStatusEnum.ENABLED
|
|
728
|
+
ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD
|
|
729
|
+
operations.append(ag_op)
|
|
730
|
+
|
|
731
|
+
# 4. Keywords (reference ad_group -3)
|
|
732
|
+
kw_list = changes.get("keywords") or []
|
|
733
|
+
for kw in kw_list:
|
|
734
|
+
kw_op = client.get_type("MutateOperation")
|
|
735
|
+
criterion = kw_op.ad_group_criterion_operation.create
|
|
736
|
+
criterion.ad_group = ad_group_service.ad_group_path(cid, "-3")
|
|
737
|
+
criterion.keyword.text = kw["text"]
|
|
738
|
+
criterion.keyword.match_type = getattr(
|
|
739
|
+
client.enums.KeywordMatchTypeEnum, kw["match_type"].upper()
|
|
740
|
+
)
|
|
741
|
+
operations.append(kw_op)
|
|
742
|
+
|
|
743
|
+
response = service.mutate(customer_id=cid, mutate_operations=operations)
|
|
744
|
+
|
|
745
|
+
resource_names = [r.mutate_operation_response for r in response.mutate_operation_responses]
|
|
746
|
+
results = {}
|
|
747
|
+
for i, resp in enumerate(response.mutate_operation_responses):
|
|
748
|
+
resp_type = resp.WhichOneof("response")
|
|
749
|
+
if resp_type:
|
|
750
|
+
inner = getattr(resp, resp_type)
|
|
751
|
+
resource = getattr(inner, "resource_name", str(inner))
|
|
752
|
+
if i == 0:
|
|
753
|
+
results["campaign_budget"] = resource
|
|
754
|
+
elif i == 1:
|
|
755
|
+
results["campaign"] = resource
|
|
756
|
+
elif i == 2:
|
|
757
|
+
results["ad_group"] = resource
|
|
758
|
+
else:
|
|
759
|
+
results.setdefault("keywords", []).append(resource)
|
|
760
|
+
|
|
761
|
+
return results
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict:
|
|
765
|
+
service = client.get_service("AdGroupAdService")
|
|
766
|
+
operation = client.get_type("AdGroupAdOperation")
|
|
767
|
+
ad_group_ad = operation.create
|
|
768
|
+
|
|
769
|
+
ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path(
|
|
770
|
+
cid, changes["ad_group_id"]
|
|
771
|
+
)
|
|
772
|
+
# Create as PAUSED for safety — user can enable separately
|
|
773
|
+
ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED
|
|
774
|
+
|
|
775
|
+
ad = ad_group_ad.ad
|
|
776
|
+
ad.final_urls.append(changes["final_url"])
|
|
777
|
+
|
|
778
|
+
for text in changes["headlines"]:
|
|
779
|
+
asset = client.get_type("AdTextAsset")
|
|
780
|
+
asset.text = text
|
|
781
|
+
ad.responsive_search_ad.headlines.append(asset)
|
|
782
|
+
|
|
783
|
+
for text in changes["descriptions"]:
|
|
784
|
+
asset = client.get_type("AdTextAsset")
|
|
785
|
+
asset.text = text
|
|
786
|
+
ad.responsive_search_ad.descriptions.append(asset)
|
|
787
|
+
|
|
788
|
+
if changes.get("path1"):
|
|
789
|
+
ad.responsive_search_ad.path1 = changes["path1"]
|
|
790
|
+
if changes.get("path2"):
|
|
791
|
+
ad.responsive_search_ad.path2 = changes["path2"]
|
|
792
|
+
|
|
793
|
+
response = service.mutate_ad_group_ads(
|
|
794
|
+
customer_id=cid, operations=[operation]
|
|
795
|
+
)
|
|
796
|
+
return {"resource_name": response.results[0].resource_name}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict:
|
|
800
|
+
service = client.get_service("AdGroupCriterionService")
|
|
801
|
+
ad_group_path = client.get_service("AdGroupService").ad_group_path(
|
|
802
|
+
cid, changes["ad_group_id"]
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
operations = []
|
|
806
|
+
for kw in changes["keywords"]:
|
|
807
|
+
operation = client.get_type("AdGroupCriterionOperation")
|
|
808
|
+
criterion = operation.create
|
|
809
|
+
criterion.ad_group = ad_group_path
|
|
810
|
+
criterion.keyword.text = kw["text"]
|
|
811
|
+
criterion.keyword.match_type = getattr(
|
|
812
|
+
client.enums.KeywordMatchTypeEnum, kw["match_type"].upper()
|
|
813
|
+
)
|
|
814
|
+
operations.append(operation)
|
|
815
|
+
|
|
816
|
+
response = service.mutate_ad_group_criteria(
|
|
817
|
+
customer_id=cid, operations=operations
|
|
818
|
+
)
|
|
819
|
+
return {"resource_names": [r.resource_name for r in response.results]}
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict:
|
|
823
|
+
service = client.get_service("CampaignCriterionService")
|
|
824
|
+
campaign_path = client.get_service("CampaignService").campaign_path(
|
|
825
|
+
cid, changes["campaign_id"]
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
operations = []
|
|
829
|
+
for kw_text in changes["keywords"]:
|
|
830
|
+
operation = client.get_type("CampaignCriterionOperation")
|
|
831
|
+
criterion = operation.create
|
|
832
|
+
criterion.campaign = campaign_path
|
|
833
|
+
criterion.negative = True
|
|
834
|
+
criterion.keyword.text = kw_text
|
|
835
|
+
criterion.keyword.match_type = getattr(
|
|
836
|
+
client.enums.KeywordMatchTypeEnum, changes["match_type"]
|
|
837
|
+
)
|
|
838
|
+
operations.append(operation)
|
|
839
|
+
|
|
840
|
+
response = service.mutate_campaign_criteria(
|
|
841
|
+
customer_id=cid, operations=operations
|
|
842
|
+
)
|
|
843
|
+
return {"resource_names": [r.resource_name for r in response.results]}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _apply_remove(
|
|
847
|
+
client: object,
|
|
848
|
+
cid: str,
|
|
849
|
+
entity_type: str,
|
|
850
|
+
entity_id: str,
|
|
851
|
+
) -> dict:
|
|
852
|
+
"""Remove an entity via the REMOVE mutate operation (irreversible)."""
|
|
853
|
+
if entity_type == "campaign":
|
|
854
|
+
service = client.get_service("CampaignService")
|
|
855
|
+
operation = client.get_type("CampaignOperation")
|
|
856
|
+
operation.remove = service.campaign_path(cid, entity_id)
|
|
857
|
+
response = service.mutate_campaigns(
|
|
858
|
+
customer_id=cid, operations=[operation]
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
elif entity_type == "ad_group":
|
|
862
|
+
service = client.get_service("AdGroupService")
|
|
863
|
+
operation = client.get_type("AdGroupOperation")
|
|
864
|
+
operation.remove = service.ad_group_path(cid, entity_id)
|
|
865
|
+
response = service.mutate_ad_groups(
|
|
866
|
+
customer_id=cid, operations=[operation]
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
elif entity_type == "ad":
|
|
870
|
+
service = client.get_service("AdGroupAdService")
|
|
871
|
+
operation = client.get_type("AdGroupAdOperation")
|
|
872
|
+
operation.remove = f"customers/{cid}/adGroupAds/{entity_id}"
|
|
873
|
+
response = service.mutate_ad_group_ads(
|
|
874
|
+
customer_id=cid, operations=[operation]
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
elif entity_type == "keyword":
|
|
878
|
+
service = client.get_service("AdGroupCriterionService")
|
|
879
|
+
operation = client.get_type("AdGroupCriterionOperation")
|
|
880
|
+
operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}"
|
|
881
|
+
response = service.mutate_ad_group_criteria(
|
|
882
|
+
customer_id=cid, operations=[operation]
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
elif entity_type == "negative_keyword":
|
|
886
|
+
service = client.get_service("CampaignCriterionService")
|
|
887
|
+
operation = client.get_type("CampaignCriterionOperation")
|
|
888
|
+
operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}"
|
|
889
|
+
response = service.mutate_campaign_criteria(
|
|
890
|
+
customer_id=cid, operations=[operation]
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
else:
|
|
894
|
+
raise ValueError(f"Cannot remove entity_type: {entity_type}")
|
|
895
|
+
|
|
896
|
+
return {"resource_name": response.results[0].resource_name}
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _apply_status_change(
|
|
900
|
+
client: object,
|
|
901
|
+
cid: str,
|
|
902
|
+
entity_type: str,
|
|
903
|
+
entity_id: str,
|
|
904
|
+
status: str,
|
|
905
|
+
) -> dict:
|
|
906
|
+
"""Update the status of a campaign, ad group, ad, or keyword."""
|
|
907
|
+
if entity_type == "campaign":
|
|
908
|
+
service = client.get_service("CampaignService")
|
|
909
|
+
operation = client.get_type("CampaignOperation")
|
|
910
|
+
entity = operation.update
|
|
911
|
+
entity.resource_name = service.campaign_path(cid, entity_id)
|
|
912
|
+
entity.status = getattr(client.enums.CampaignStatusEnum, status)
|
|
913
|
+
mutate = service.mutate_campaigns
|
|
914
|
+
|
|
915
|
+
elif entity_type == "ad_group":
|
|
916
|
+
service = client.get_service("AdGroupService")
|
|
917
|
+
operation = client.get_type("AdGroupOperation")
|
|
918
|
+
entity = operation.update
|
|
919
|
+
entity.resource_name = service.ad_group_path(cid, entity_id)
|
|
920
|
+
entity.status = getattr(client.enums.AdGroupStatusEnum, status)
|
|
921
|
+
mutate = service.mutate_ad_groups
|
|
922
|
+
|
|
923
|
+
elif entity_type == "ad":
|
|
924
|
+
service = client.get_service("AdGroupAdService")
|
|
925
|
+
operation = client.get_type("AdGroupAdOperation")
|
|
926
|
+
entity = operation.update
|
|
927
|
+
entity.resource_name = f"customers/{cid}/adGroupAds/{entity_id}"
|
|
928
|
+
entity.status = getattr(client.enums.AdGroupAdStatusEnum, status)
|
|
929
|
+
mutate = service.mutate_ad_group_ads
|
|
930
|
+
|
|
931
|
+
elif entity_type == "keyword":
|
|
932
|
+
service = client.get_service("AdGroupCriterionService")
|
|
933
|
+
operation = client.get_type("AdGroupCriterionOperation")
|
|
934
|
+
entity = operation.update
|
|
935
|
+
entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}"
|
|
936
|
+
entity.status = getattr(
|
|
937
|
+
client.enums.AdGroupCriterionStatusEnum, status
|
|
938
|
+
)
|
|
939
|
+
mutate = service.mutate_ad_group_criteria
|
|
940
|
+
|
|
941
|
+
else:
|
|
942
|
+
raise ValueError(f"Unknown entity_type: {entity_type}")
|
|
943
|
+
|
|
944
|
+
# Build field mask for the status field only
|
|
945
|
+
field_mask = client.get_type("FieldMask")
|
|
946
|
+
field_mask.paths.append("status")
|
|
947
|
+
client.copy_from(operation.update_mask, field_mask)
|
|
948
|
+
|
|
949
|
+
response = mutate(customer_id=cid, operations=[operation])
|
|
950
|
+
return {"resource_name": response.results[0].resource_name}
|