adloop 0.31.0__tar.gz → 0.32.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.31.0 → adloop-0.32.0}/PKG-INFO +1 -1
- {adloop-0.31.0 → adloop-0.32.0}/pyproject.toml +1 -1
- {adloop-0.31.0 → adloop-0.32.0}/pyproject.toml.orig +1 -1
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/write.py +142 -17
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/adloop.md +3 -3
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/approval_card.html +13 -3
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/server.py +9 -8
- {adloop-0.31.0 → adloop-0.32.0}/README.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/__main__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/auth.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/cli.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/config.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/crossref.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/runtime.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/tracking.py +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.31.0 → adloop-0.32.0}/src/adloop/uploads.py +0 -0
|
@@ -550,9 +550,10 @@ def draft_responsive_search_ad(
|
|
|
550
550
|
headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3
|
|
551
551
|
descriptions: DESCRIPTION_1, DESCRIPTION_2
|
|
552
552
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
553
|
+
Several assets may pin to the same position (Google rotates them there),
|
|
554
|
+
as long as every position stays fillable: by an asset pinned to it or by
|
|
555
|
+
a distinct unpinned asset. Mixed plain-string and dict entries are allowed
|
|
556
|
+
within a single call (e.g. brand pinned to HEADLINE_1, the rest unpinned).
|
|
556
557
|
"""
|
|
557
558
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
558
559
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
@@ -2055,12 +2056,19 @@ def remove_entity(
|
|
|
2055
2056
|
if entity_type in ("campaign_asset", "customer_asset"):
|
|
2056
2057
|
entity_id = entity_id.replace(",", "~")
|
|
2057
2058
|
|
|
2059
|
+
names, not_found = _describe_target(config, customer_id, entity_type, entity_id)
|
|
2060
|
+
if not_found:
|
|
2061
|
+
return {"error": not_found}
|
|
2062
|
+
|
|
2058
2063
|
plan = ChangePlan(
|
|
2059
2064
|
operation="remove_entity",
|
|
2060
2065
|
entity_type=entity_type,
|
|
2061
2066
|
entity_id=entity_id,
|
|
2062
2067
|
customer_id=customer_id,
|
|
2063
|
-
changes={"action": "REMOVE"
|
|
2068
|
+
changes={"action": "REMOVE", **names, **(
|
|
2069
|
+
{"warnings": ["This entity is already REMOVED; applying changes nothing."]}
|
|
2070
|
+
if names.get("current_status") == "REMOVED" else {}
|
|
2071
|
+
)},
|
|
2064
2072
|
# Removal is irreversible; the preview asks for a second explicit
|
|
2065
2073
|
# confirmation, as Reddit removals already did.
|
|
2066
2074
|
requires_double_confirm=True,
|
|
@@ -3457,7 +3465,6 @@ def _validate_rsa_assets(
|
|
|
3457
3465
|
if len(descriptions) > 4:
|
|
3458
3466
|
errors.append(f"Maximum 4 descriptions, got {len(descriptions)}")
|
|
3459
3467
|
|
|
3460
|
-
headline_pin_counts: dict[str, int] = {}
|
|
3461
3468
|
for i, h in enumerate(headlines):
|
|
3462
3469
|
text = h["text"]
|
|
3463
3470
|
pin = h["pinned_field"]
|
|
@@ -3472,13 +3479,8 @@ def _validate_rsa_assets(
|
|
|
3472
3479
|
f"Headline {i + 1} pinned_field '{pin}' invalid; "
|
|
3473
3480
|
f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null"
|
|
3474
3481
|
)
|
|
3475
|
-
|
|
3476
|
-
headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1
|
|
3477
|
-
for pin, count in headline_pin_counts.items():
|
|
3478
|
-
if count > 2:
|
|
3479
|
-
errors.append(f"At most 2 headlines may pin to {pin}; got {count}")
|
|
3482
|
+
errors.extend(_pin_fill_errors("headline", headlines, _VALID_HEADLINE_PINS))
|
|
3480
3483
|
|
|
3481
|
-
description_pin_counts: dict[str, int] = {}
|
|
3482
3484
|
for i, d in enumerate(descriptions):
|
|
3483
3485
|
text = d["text"]
|
|
3484
3486
|
pin = d["pinned_field"]
|
|
@@ -3493,15 +3495,37 @@ def _validate_rsa_assets(
|
|
|
3493
3495
|
f"Description {i + 1} pinned_field '{pin}' invalid; "
|
|
3494
3496
|
f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null"
|
|
3495
3497
|
)
|
|
3496
|
-
|
|
3497
|
-
description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1
|
|
3498
|
-
for pin, count in description_pin_counts.items():
|
|
3499
|
-
if count > 1:
|
|
3500
|
-
errors.append(f"At most 1 description may pin to {pin}; got {count}")
|
|
3498
|
+
errors.extend(_pin_fill_errors("description", descriptions, _VALID_DESCRIPTION_PINS))
|
|
3501
3499
|
|
|
3502
3500
|
return errors
|
|
3503
3501
|
|
|
3504
3502
|
|
|
3503
|
+
def _pin_fill_errors(kind: str, assets: list[dict], positions: set[str]) -> list[str]:
|
|
3504
|
+
"""Google's pinning rule: every position must be fillable, either by an
|
|
3505
|
+
asset pinned to it or by a distinct unpinned asset.
|
|
3506
|
+
|
|
3507
|
+
Google sets no cap on how many assets pin to one position (they rotate
|
|
3508
|
+
there), so several headlines pinned to HEADLINE_1 are fine as long as the
|
|
3509
|
+
other positions can still be filled. Breaking the rule fails at Google with
|
|
3510
|
+
NOT_ENOUGH_AVAILABLE_ASSET_LINKS_FOR_VALID_COMBINATION.
|
|
3511
|
+
"""
|
|
3512
|
+
if not assets:
|
|
3513
|
+
return []
|
|
3514
|
+
pinned = {a["pinned_field"] for a in assets if a.get("pinned_field") in positions}
|
|
3515
|
+
if not pinned:
|
|
3516
|
+
return []
|
|
3517
|
+
empty = sorted(positions - pinned)
|
|
3518
|
+
unpinned = sum(1 for a in assets if not a.get("pinned_field"))
|
|
3519
|
+
if unpinned >= len(empty):
|
|
3520
|
+
return []
|
|
3521
|
+
return [
|
|
3522
|
+
f"Pinning leaves {', '.join(empty)} without enough {kind}s: "
|
|
3523
|
+
f"{len(empty)} position(s) have no pinned {kind} and need one distinct "
|
|
3524
|
+
f"unpinned {kind} each, but only {unpinned} {kind}(s) are unpinned. "
|
|
3525
|
+
f"Unpin a {kind}, add more unpinned ones, or pin one to each empty position."
|
|
3526
|
+
]
|
|
3527
|
+
|
|
3528
|
+
|
|
3505
3529
|
def _validate_rsa(
|
|
3506
3530
|
ad_group_id: str,
|
|
3507
3531
|
headlines: list[dict],
|
|
@@ -3815,6 +3839,98 @@ def _preflight_ad_group_checks(
|
|
|
3815
3839
|
return errors, warnings
|
|
3816
3840
|
|
|
3817
3841
|
|
|
3842
|
+
# What the preview of a pause, enable or remove names besides the IDs:
|
|
3843
|
+
# (query resource, id fields, name field, extra fields) per entity type. The
|
|
3844
|
+
# names reach the approval card and the confirmation dialog, where an ID alone
|
|
3845
|
+
# is hard to check, and a lookup that finds nothing exposes a mistyped ID.
|
|
3846
|
+
_TARGET_LOOKUPS: dict[str, tuple[str, tuple[str, ...], str, dict[str, str]]] = {
|
|
3847
|
+
"campaign": ("campaign", ("campaign.id",), "campaign.name",
|
|
3848
|
+
{"current_status": "campaign.status"}),
|
|
3849
|
+
"ad_group": ("ad_group", ("ad_group.id",), "ad_group.name",
|
|
3850
|
+
{"campaign_name": "campaign.name", "current_status": "ad_group.status"}),
|
|
3851
|
+
"ad": ("ad_group_ad", ("ad_group_ad.ad.id",), "ad_group_ad.ad.name",
|
|
3852
|
+
{"ad_group_name": "ad_group.name", "campaign_name": "campaign.name",
|
|
3853
|
+
"current_status": "ad_group_ad.status"}),
|
|
3854
|
+
"keyword": ("ad_group_criterion", ("ad_group.id", "ad_group_criterion.criterion_id"),
|
|
3855
|
+
"ad_group_criterion.keyword.text",
|
|
3856
|
+
{"match_type": "ad_group_criterion.keyword.match_type",
|
|
3857
|
+
"ad_group_name": "ad_group.name", "campaign_name": "campaign.name",
|
|
3858
|
+
"current_status": "ad_group_criterion.status"}),
|
|
3859
|
+
"asset_group": ("asset_group", ("asset_group.id",), "asset_group.name",
|
|
3860
|
+
{"campaign_name": "campaign.name", "current_status": "asset_group.status"}),
|
|
3861
|
+
"negative_keyword": ("campaign_criterion", ("campaign.id", "campaign_criterion.criterion_id"),
|
|
3862
|
+
"campaign_criterion.keyword.text", {"campaign_name": "campaign.name"}),
|
|
3863
|
+
"campaign_criterion": ("campaign_criterion", ("campaign.id", "campaign_criterion.criterion_id"),
|
|
3864
|
+
"campaign_criterion.type", {"campaign_name": "campaign.name"}),
|
|
3865
|
+
"ad_group_criterion": ("ad_group_criterion", ("ad_group.id", "ad_group_criterion.criterion_id"),
|
|
3866
|
+
"ad_group_criterion.type",
|
|
3867
|
+
{"ad_group_name": "ad_group.name", "campaign_name": "campaign.name"}),
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
|
|
3871
|
+
def _format_customer(customer_id: object) -> str:
|
|
3872
|
+
digits = str(customer_id or "").replace("-", "")
|
|
3873
|
+
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" if len(digits) == 10 else digits
|
|
3874
|
+
|
|
3875
|
+
|
|
3876
|
+
def _describe_target(
|
|
3877
|
+
config: AdLoopConfig, customer_id: str, entity_type: str, entity_id: str
|
|
3878
|
+
) -> tuple[dict, str | None]:
|
|
3879
|
+
"""Names for a status change or removal preview: ``({names}, error)``.
|
|
3880
|
+
|
|
3881
|
+
One GAQL query reads the account name and, for the entity types above,
|
|
3882
|
+
the entity's name, its campaign or ad group and its current status. A
|
|
3883
|
+
failed lookup (API error, an ID format this does not know) returns no
|
|
3884
|
+
names and no error, so the preview falls back to the IDs. A lookup that
|
|
3885
|
+
succeeds but finds no such entity returns an error: the ID is wrong.
|
|
3886
|
+
"""
|
|
3887
|
+
from adloop.ads.gaql import execute_query
|
|
3888
|
+
|
|
3889
|
+
spec = _TARGET_LOOKUPS.get(entity_type)
|
|
3890
|
+
parts = entity_id.split("~")
|
|
3891
|
+
if entity_type == "ad" and len(parts) == 2:
|
|
3892
|
+
parts = parts[1:] # adGroupId~adId: the ad id alone identifies it
|
|
3893
|
+
if spec and (len(parts) != len(spec[1]) or not all(p.isdigit() for p in parts)):
|
|
3894
|
+
spec = None
|
|
3895
|
+
try:
|
|
3896
|
+
if spec:
|
|
3897
|
+
resource, id_fields, name_field, extra = spec
|
|
3898
|
+
fields = ["customer.descriptive_name", "customer.id", name_field, *extra.values()]
|
|
3899
|
+
where = " AND ".join(f"{f} = {v}" for f, v in zip(id_fields, parts))
|
|
3900
|
+
rows = execute_query(
|
|
3901
|
+
config, customer_id,
|
|
3902
|
+
f"SELECT {', '.join(fields)} FROM {resource} WHERE {where} LIMIT 1",
|
|
3903
|
+
)
|
|
3904
|
+
else:
|
|
3905
|
+
rows = execute_query(
|
|
3906
|
+
config, customer_id, "SELECT customer.descriptive_name, customer.id FROM customer LIMIT 1"
|
|
3907
|
+
)
|
|
3908
|
+
except Exception:
|
|
3909
|
+
return {}, None
|
|
3910
|
+
if not rows:
|
|
3911
|
+
if spec:
|
|
3912
|
+
return {}, (
|
|
3913
|
+
f"No {entity_type.replace('_', ' ')} with ID {entity_id} in account "
|
|
3914
|
+
f"{_format_customer(customer_id) or 'the default account'}. Check the ID "
|
|
3915
|
+
"with a read tool (get_campaign_performance, get_ad_performance, ...)."
|
|
3916
|
+
)
|
|
3917
|
+
return {}, None
|
|
3918
|
+
row = rows[0]
|
|
3919
|
+
names: dict = {}
|
|
3920
|
+
account = row.get("customer.descriptive_name") or ""
|
|
3921
|
+
cid = _format_customer(row.get("customer.id"))
|
|
3922
|
+
if account or cid:
|
|
3923
|
+
names["account"] = f"{account} ({cid})" if account and cid else account or cid
|
|
3924
|
+
if spec:
|
|
3925
|
+
name = row.get(spec[2])
|
|
3926
|
+
if name:
|
|
3927
|
+
names["entity_name"] = str(name)
|
|
3928
|
+
for key, field_name in spec[3].items():
|
|
3929
|
+
if row.get(field_name) not in (None, ""):
|
|
3930
|
+
names[key] = str(row[field_name])
|
|
3931
|
+
return names, None
|
|
3932
|
+
|
|
3933
|
+
|
|
3818
3934
|
def _draft_status_change(
|
|
3819
3935
|
config: AdLoopConfig,
|
|
3820
3936
|
operation: str,
|
|
@@ -3841,12 +3957,21 @@ def _draft_status_change(
|
|
|
3841
3957
|
if errors:
|
|
3842
3958
|
return {"error": "Validation failed", "details": errors}
|
|
3843
3959
|
|
|
3960
|
+
names, not_found = _describe_target(config, customer_id, entity_type, entity_id)
|
|
3961
|
+
if not_found:
|
|
3962
|
+
return {"error": not_found}
|
|
3963
|
+
changes: dict = {"target_status": target_status, **names}
|
|
3964
|
+
if names.get("current_status") == target_status:
|
|
3965
|
+
changes["warnings"] = [
|
|
3966
|
+
f"This {entity_type.replace('_', ' ')} is already {target_status}; applying changes nothing."
|
|
3967
|
+
]
|
|
3968
|
+
|
|
3844
3969
|
plan = ChangePlan(
|
|
3845
3970
|
operation=operation,
|
|
3846
3971
|
entity_type=entity_type,
|
|
3847
3972
|
entity_id=entity_id,
|
|
3848
3973
|
customer_id=customer_id,
|
|
3849
|
-
changes=
|
|
3974
|
+
changes=changes,
|
|
3850
3975
|
)
|
|
3851
3976
|
store_plan(plan)
|
|
3852
3977
|
return plan.to_preview()
|
|
@@ -270,7 +270,7 @@ For images that have no public URL: a file on the user's computer, or an image t
|
|
|
270
270
|
| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) |
|
|
271
271
|
| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` |
|
|
272
272
|
| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` |
|
|
273
|
-
| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`.
|
|
273
|
+
| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. |
|
|
274
274
|
| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
|
|
275
275
|
| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values |
|
|
276
276
|
| `draft_image_assets` | Create image assets for a campaign from local files, public image URLs or uploads (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server), `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) and/or `upload_ids` (`up_...` from `upload_images` / `list_uploads`) |
|
|
@@ -427,7 +427,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
|
|
|
427
427
|
7. **Pinning (use sparingly).** By default, pass headlines and descriptions as plain strings — Google rotates them for you and ad strength benefits from the diversity. Only switch to the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` when the user has a concrete brand-safety, compliance, or messaging requirement (e.g. "the brand name must always appear", "the disclaimer must always show"). Trade-offs to flag before pinning:
|
|
428
428
|
- Each pin reduces Google's ability to optimize headline order, which usually drops ad strength one notch.
|
|
429
429
|
- Phone numbers should generally use a **call asset at the campaign level** (separate tool/manual setup) rather than a pinned headline — call assets are clickable on mobile and don't burn a headline slot. If the user asks to pin a phone number, suggest the call-asset path first.
|
|
430
|
-
- Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions.
|
|
430
|
+
- Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions. Several assets may pin to the same slot and rotate there (Google suggests 2 or 3 unique ones per pinned slot), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. Pinning 3 keyword headlines to `HEADLINE_1` with 10 unpinned headlines is fine; pinning all 3 headlines of a 3-headline ad to `HEADLINE_1` is refused.
|
|
431
431
|
- You can mix pinned dict entries with plain-string entries in a single call (e.g. brand pinned to `HEADLINE_1`, the rest unpinned).
|
|
432
432
|
8. **Always set display paths** (`path1`, `path2`, max 15 chars each). These appear in the display URL (e.g. `example.com/Products/Pricing`) and significantly improve ad relevance. Derive them from the landing page URL structure or the ad's value proposition.
|
|
433
433
|
9. Present the complete preview to the user — include any warnings from the pre-write checks, and explicitly call out which assets (if any) are pinned and the trade-off.
|
|
@@ -576,7 +576,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
576
576
|
|
|
577
577
|
1. Call the appropriate read tool to confirm the entity exists and get its current status
|
|
578
578
|
2. Call `pause_entity` or `enable_entity` (Google) or `pause_reddit_entity` / `enable_reddit_entity` (Reddit) with the entity type and ID
|
|
579
|
-
3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign")
|
|
579
|
+
3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign"). Google Ads previews of pause, enable and remove name the target: `entity_name`, `account`, the parent `campaign_name` / `ad_group_name` and `current_status`. Repeat the names to the user, because a wrong account is the costly mistake; an ID that matches nothing is refused at draft time
|
|
580
580
|
4. Wait for confirmation
|
|
581
581
|
|
|
582
582
|
### When user asks about landing page performance
|
|
@@ -203,15 +203,25 @@
|
|
|
203
203
|
const p = plan.preview;
|
|
204
204
|
$("platform").textContent = PLATFORMS[platformOf(p)];
|
|
205
205
|
$("title").textContent = humanize(p.operation);
|
|
206
|
+
// Names resolved at draft time (pause, enable, remove) sit in changes;
|
|
207
|
+
// the IDs stay visible next to them, and the card falls back to IDs.
|
|
208
|
+
const ch = p.changes || {};
|
|
206
209
|
const sub = [];
|
|
207
|
-
if (p.entity_type)
|
|
208
|
-
|
|
210
|
+
if (p.entity_type) {
|
|
211
|
+
let target = String(p.entity_type).replace(/_/g, " ");
|
|
212
|
+
if (ch.entity_name) target += " \u201c" + ch.entity_name + "\u201d" + (p.entity_id ? " (" + p.entity_id + ")" : "");
|
|
213
|
+
else if (p.entity_id) target += " " + p.entity_id;
|
|
214
|
+
sub.push(target);
|
|
215
|
+
}
|
|
216
|
+
if (ch.account) sub.push(String(ch.account));
|
|
217
|
+
else if (p.customer_id) sub.push(String(p.customer_id));
|
|
209
218
|
sub.push("plan " + plan.planId);
|
|
210
219
|
$("subtitle").textContent = sub.join(" · ");
|
|
211
220
|
|
|
212
221
|
const dl = $("changes");
|
|
213
222
|
dl.replaceChildren();
|
|
214
|
-
const
|
|
223
|
+
const SHOWN_ELSEWHERE = new Set(["account", "entity_name", "warnings"]);
|
|
224
|
+
const entries = Object.entries(ch).filter(([k]) => !SHOWN_ELSEWHERE.has(k));
|
|
215
225
|
entries.slice(0, MAX_ROWS).forEach(([k, v]) => row(dl, k, v));
|
|
216
226
|
if (entries.length > MAX_ROWS) {
|
|
217
227
|
const more = document.createElement("div");
|
|
@@ -3406,10 +3406,11 @@ def draft_responsive_search_ad(
|
|
|
3406
3406
|
|
|
3407
3407
|
The preview shows exactly what will be created; confirm_and_apply executes
|
|
3408
3408
|
the returned plan_id. The ad is created PAUSED. final_url is checked for
|
|
3409
|
-
reachability first and an unreachable URL is refused.
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3409
|
+
reachability first and an unreachable URL is refused. Several headlines
|
|
3410
|
+
may pin to the same position (Google rotates them there) as long as every
|
|
3411
|
+
position stays fillable, by an asset pinned to it or by a distinct
|
|
3412
|
+
unpinned one. Mixed plain-string and dict entries are allowed within a
|
|
3413
|
+
single call (e.g. brand pinned to HEADLINE_1, the rest unpinned). Warnings note fewer than 8
|
|
3413
3414
|
headlines or 3 descriptions.
|
|
3414
3415
|
|
|
3415
3416
|
Args:
|
|
@@ -3483,15 +3484,15 @@ def update_responsive_search_ad(
|
|
|
3483
3484
|
uses the configured default account.
|
|
3484
3485
|
headlines: LIST-REPLACE: None or [] means no change; a non-empty list
|
|
3485
3486
|
fully swaps in for the existing headlines. Google's RSA constraints
|
|
3486
|
-
apply: 3-15 headlines, max 30 chars each,
|
|
3487
|
+
apply: 3-15 headlines, max 30 chars each, every pin position fillable.
|
|
3487
3488
|
Each entry is a plain string (unpinned) or {"text": "...",
|
|
3488
3489
|
"pinned_field": "HEADLINE_1"} (HEADLINE_1-3). Insertion tags such as
|
|
3489
3490
|
{KeyWord:Clogged Drain Service} count as their default text, which
|
|
3490
3491
|
is required.
|
|
3491
3492
|
descriptions: LIST-REPLACE: None or [] means no change; a non-empty list
|
|
3492
|
-
fully swaps in. 2-4 descriptions, max 90 chars each,
|
|
3493
|
-
|
|
3494
|
-
insertion-tag counting as headlines.
|
|
3493
|
+
fully swaps in. 2-4 descriptions, max 90 chars each, pins to
|
|
3494
|
+
DESCRIPTION_1 or DESCRIPTION_2 with every position fillable. Same
|
|
3495
|
+
entry format and insertion-tag counting as headlines.
|
|
3495
3496
|
final_url: Empty means no change; non-empty replaces the final URL and
|
|
3496
3497
|
is checked for reachability.
|
|
3497
3498
|
path1: Empty means no change; non-empty sets the first display path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|