adloop 0.30.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.30.0 → adloop-0.32.0}/PKG-INFO +3 -2
- {adloop-0.30.0 → adloop-0.32.0}/README.md +2 -1
- {adloop-0.30.0 → adloop-0.32.0}/pyproject.toml +1 -1
- {adloop-0.30.0 → adloop-0.32.0}/pyproject.toml.orig +1 -1
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/write.py +142 -17
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gtm/write.py +233 -29
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/adloop.md +7 -5
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/approval_card.html +13 -3
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/server.py +89 -15
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/__main__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/auth.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/cli.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/config.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/crossref.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/runtime.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/tracking.py +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.30.0 → adloop-0.32.0}/src/adloop/uploads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.32.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
|
|
@@ -206,7 +206,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
|
|
|
206
206
|
|------|-------------|
|
|
207
207
|
| `draft_gtm_tag` | Create a tag, or update one by `tag_id`. Updates are a read-modify-write: `parameters` merge by key and every field you don't pass (priority, consent settings, firing options, schedule, folder, …) is preserved. Tag types are canonical GTM template IDs (`googtag`, `gaawe`, `awct`, `sp`, `gclidw`, …) or `cvt_<id>` gallery templates. |
|
|
208
208
|
| `draft_gtm_trigger` | Create a trigger, or update one by `trigger_id` (type is immutable). `custom_event_name` wires a `customEvent` trigger to a dataLayer event. |
|
|
209
|
-
| `
|
|
209
|
+
| `draft_gtm_variable` | Create or update variables, e.g. Data Layer Variables for GA4 ecommerce values; renaming a referenced variable is refused, Custom JavaScript is gated |
|
|
210
|
+
| `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Triggers and variables still in use are refused up front, with the references listed. |
|
|
210
211
|
| `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
|
|
211
212
|
| `draft_rollback_gtm_version` | Republish an older container version live. The preview is the diff from the live version to the target; the plan pins the live version, so apply refuses if someone publishes in between. Workspaces are left as they are. |
|
|
212
213
|
|
|
@@ -178,7 +178,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
|
|
|
178
178
|
|------|-------------|
|
|
179
179
|
| `draft_gtm_tag` | Create a tag, or update one by `tag_id`. Updates are a read-modify-write: `parameters` merge by key and every field you don't pass (priority, consent settings, firing options, schedule, folder, …) is preserved. Tag types are canonical GTM template IDs (`googtag`, `gaawe`, `awct`, `sp`, `gclidw`, …) or `cvt_<id>` gallery templates. |
|
|
180
180
|
| `draft_gtm_trigger` | Create a trigger, or update one by `trigger_id` (type is immutable). `custom_event_name` wires a `customEvent` trigger to a dataLayer event. |
|
|
181
|
-
| `
|
|
181
|
+
| `draft_gtm_variable` | Create or update variables, e.g. Data Layer Variables for GA4 ecommerce values; renaming a referenced variable is refused, Custom JavaScript is gated |
|
|
182
|
+
| `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Triggers and variables still in use are refused up front, with the references listed. |
|
|
182
183
|
| `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
|
|
183
184
|
| `draft_rollback_gtm_version` | Republish an older container version live. The preview is the diff from the live version to the target; the plan pins the live version, so apply refuses if someone publishes in between. Workspaces are left as they are. |
|
|
184
185
|
|
|
@@ -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()
|
|
@@ -18,10 +18,10 @@ Three gates sit on top of that, all enforced here rather than advised:
|
|
|
18
18
|
every page they fire on. Creating or editing one, or publishing a
|
|
19
19
|
workspace (or rolling back to a version) that adds or changes one, is
|
|
20
20
|
refused unless this is on. Pausing
|
|
21
|
-
or deleting one is always allowed (both reduce what runs).
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
reviewed in the GTM UI.
|
|
21
|
+
or deleting one is always allowed (both reduce what runs). Creating or
|
|
22
|
+
editing a Custom JavaScript variable (``jsm``) is gated the same way. A
|
|
23
|
+
custom template (``cvt_…``), which runs sandboxed code, is not covered
|
|
24
|
+
and has to be reviewed in the GTM UI.
|
|
25
25
|
* Concurrency — every update/delete pins the entity fingerprint read at draft
|
|
26
26
|
time, and publish pins a digest of the workspace's pending changes. A
|
|
27
27
|
rollback pins the live version it replaces and the target's fingerprint. If
|
|
@@ -78,6 +78,25 @@ KNOWN_TRIGGER_TYPES = {
|
|
|
78
78
|
"triggerGroup",
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
# GTM API variable type values the draft accepts. Custom templates use
|
|
82
|
+
# ``cvt_<id>`` like tags. ``jsm`` (Custom JavaScript) runs code on the site
|
|
83
|
+
# and is gated by gtm.allow_custom_html.
|
|
84
|
+
KNOWN_VARIABLE_TYPES: dict[str, str] = {
|
|
85
|
+
"v": "Data Layer Variable",
|
|
86
|
+
"c": "Constant",
|
|
87
|
+
"u": "URL",
|
|
88
|
+
"k": "1st-Party Cookie",
|
|
89
|
+
"j": "JavaScript Variable (reads a global, runs no code)",
|
|
90
|
+
"aev": "Auto-Event Variable",
|
|
91
|
+
"d": "DOM Element",
|
|
92
|
+
"f": "HTTP Referrer",
|
|
93
|
+
"e": "Custom Event",
|
|
94
|
+
"smm": "Lookup Table",
|
|
95
|
+
"remm": "RegEx Table",
|
|
96
|
+
"jsm": "Custom JavaScript (gated by gtm.allow_custom_html)",
|
|
97
|
+
}
|
|
98
|
+
CUSTOM_JAVASCRIPT = "jsm"
|
|
99
|
+
|
|
81
100
|
# Trigger request fields the draft tool can set, keyed by the tool's
|
|
82
101
|
# snake_case argument name.
|
|
83
102
|
_TRIGGER_FIELDS = {
|
|
@@ -314,25 +333,29 @@ def _summarize_changes(status: dict) -> list[dict]:
|
|
|
314
333
|
|
|
315
334
|
|
|
316
335
|
def _custom_html_changes(status: dict) -> list[dict]:
|
|
317
|
-
"""Custom HTML tags the publish would add
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
and c["tag"].get("type") == CUSTOM_HTML
|
|
324
|
-
|
|
325
|
-
|
|
336
|
+
"""Custom HTML tags and Custom JavaScript variables the publish would add
|
|
337
|
+
or change (deletes are fine)."""
|
|
338
|
+
out = []
|
|
339
|
+
for c in status.get("workspaceChange", []) or []:
|
|
340
|
+
if c.get("changeStatus") == "deleted":
|
|
341
|
+
continue
|
|
342
|
+
if "tag" in c and c["tag"].get("type") == CUSTOM_HTML:
|
|
343
|
+
out.append({"tag_id": c["tag"].get("tagId"), "name": c["tag"].get("name"),
|
|
344
|
+
"change_status": c.get("changeStatus")})
|
|
345
|
+
elif "variable" in c and c["variable"].get("type") == CUSTOM_JAVASCRIPT:
|
|
346
|
+
out.append({"variable_id": c["variable"].get("variableId"), "name": c["variable"].get("name"),
|
|
347
|
+
"change_status": c.get("changeStatus")})
|
|
348
|
+
return out
|
|
326
349
|
|
|
327
350
|
|
|
328
351
|
def _custom_html_pending_changes(pending: list[dict]) -> list[dict]:
|
|
329
352
|
"""The same check against a stored preview's summarized pending changes."""
|
|
330
353
|
return [
|
|
331
|
-
{"
|
|
354
|
+
{"entity_kind": c.get("entity_kind"), "id": c.get("entity_id"), "name": c.get("name"),
|
|
332
355
|
"change_status": c.get("change_status")}
|
|
333
356
|
for c in pending
|
|
334
|
-
if c.get("entity_kind") == "tag"
|
|
335
|
-
|
|
357
|
+
if ((c.get("entity_kind") == "tag" and c.get("type") == CUSTOM_HTML)
|
|
358
|
+
or (c.get("entity_kind") == "variable" and c.get("type") == CUSTOM_JAVASCRIPT))
|
|
336
359
|
and c.get("change_status") != "deleted"
|
|
337
360
|
]
|
|
338
361
|
|
|
@@ -610,6 +633,152 @@ def draft_gtm_trigger(
|
|
|
610
633
|
return _store(operation, "gtm_trigger", trigger_id, changes, warnings=warnings)
|
|
611
634
|
|
|
612
635
|
|
|
636
|
+
def _variable_references(ws, ws_path: str, name: str, *, skip_variable_id: str = "") -> list[dict]:
|
|
637
|
+
"""Tags, triggers and variables whose fields mention {{name}}."""
|
|
638
|
+
if not name:
|
|
639
|
+
return []
|
|
640
|
+
needle = "{{" + name + "}}"
|
|
641
|
+
found = []
|
|
642
|
+
for kind, method, id_field in (("tag", ws.tags, "tagId"), ("trigger", ws.triggers, "triggerId"),
|
|
643
|
+
("variable", ws.variables, "variableId")):
|
|
644
|
+
for entity in method().list(parent=ws_path).execute().get(kind, []) or []:
|
|
645
|
+
if kind == "variable" and str(entity.get(id_field)) == skip_variable_id:
|
|
646
|
+
continue
|
|
647
|
+
if needle in json.dumps(entity):
|
|
648
|
+
found.append({"kind": kind, "id": entity.get(id_field), "name": entity.get("name")})
|
|
649
|
+
return found
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def draft_gtm_variable(
|
|
653
|
+
config: AdLoopConfig,
|
|
654
|
+
*,
|
|
655
|
+
account_id: str,
|
|
656
|
+
container_id: str,
|
|
657
|
+
variable_id: str = "",
|
|
658
|
+
workspace_id: str = "",
|
|
659
|
+
name: str = "",
|
|
660
|
+
variable_type: str = "",
|
|
661
|
+
data_layer_key: str = "",
|
|
662
|
+
default_value: str | None = None,
|
|
663
|
+
parameters: list[dict] | None = None,
|
|
664
|
+
notes: str | None = None,
|
|
665
|
+
) -> dict:
|
|
666
|
+
"""Create (no ``variable_id``) or update (``variable_id`` set) a variable."""
|
|
667
|
+
_require_writes(config)
|
|
668
|
+
operation = "gtm_update_variable" if variable_id else "gtm_create_variable"
|
|
669
|
+
blocked = _blocked(operation, config)
|
|
670
|
+
if blocked:
|
|
671
|
+
return blocked
|
|
672
|
+
|
|
673
|
+
errors: list[str] = _validate_parameters(parameters, "parameters")
|
|
674
|
+
if name and (len(name) > 200 or "{" in name or "}" in name):
|
|
675
|
+
errors.append("name must be 1-200 characters without curly braces")
|
|
676
|
+
if variable_id and variable_type:
|
|
677
|
+
errors.append("variable_type cannot be changed on an existing variable; delete it and create a new one")
|
|
678
|
+
if not variable_id:
|
|
679
|
+
if not name:
|
|
680
|
+
errors.append("name is required when creating a variable")
|
|
681
|
+
if variable_type not in KNOWN_VARIABLE_TYPES and not variable_type.startswith(_CUSTOM_TEMPLATE_PREFIX):
|
|
682
|
+
errors.append(
|
|
683
|
+
f"variable_type '{variable_type}' invalid; valid: "
|
|
684
|
+
+ ", ".join(f"{k} ({v})" for k, v in KNOWN_VARIABLE_TYPES.items())
|
|
685
|
+
+ ", or cvt_<id> for a custom template"
|
|
686
|
+
)
|
|
687
|
+
if variable_type == "v" and not data_layer_key and not any(
|
|
688
|
+
p.get("key") == "name" for p in parameters or []
|
|
689
|
+
):
|
|
690
|
+
errors.append("data_layer_key is required for a Data Layer Variable, e.g. 'ecommerce.value'")
|
|
691
|
+
if (data_layer_key or default_value is not None) and variable_type and variable_type != "v":
|
|
692
|
+
errors.append("data_layer_key and default_value only apply to Data Layer Variables (variable_type='v')")
|
|
693
|
+
if variable_type == CUSTOM_JAVASCRIPT and not config.gtm.allow_custom_html:
|
|
694
|
+
return _custom_html_refusal("a Custom JavaScript variable")
|
|
695
|
+
if errors:
|
|
696
|
+
return {"error": "Validation failed", "details": errors}
|
|
697
|
+
|
|
698
|
+
# Data Layer Variable fields, from the shortcut arguments.
|
|
699
|
+
shortcut: list[dict] = []
|
|
700
|
+
if data_layer_key:
|
|
701
|
+
shortcut += [
|
|
702
|
+
{"type": "INTEGER", "key": "dataLayerVersion", "value": "2"},
|
|
703
|
+
{"type": "TEMPLATE", "key": "name", "value": data_layer_key},
|
|
704
|
+
]
|
|
705
|
+
if default_value is not None:
|
|
706
|
+
shortcut += [
|
|
707
|
+
{"type": "BOOLEAN", "key": "setDefaultValue", "value": "true"},
|
|
708
|
+
{"type": "TEMPLATE", "key": "defaultValue", "value": default_value},
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
client = _client(config)
|
|
712
|
+
ws_id, ws_name = _resolve_workspace(client, account_id, container_id, workspace_id)
|
|
713
|
+
base = {
|
|
714
|
+
"account_id": account_id,
|
|
715
|
+
"container_id": container_id,
|
|
716
|
+
"workspace_id": ws_id,
|
|
717
|
+
"workspace_name": ws_name,
|
|
718
|
+
}
|
|
719
|
+
ws = _workspaces(client)
|
|
720
|
+
warnings = [
|
|
721
|
+
f"This edits workspace '{ws_name}' only. Nothing changes on the site "
|
|
722
|
+
"until the workspace is published (draft_publish_gtm_workspace)."
|
|
723
|
+
]
|
|
724
|
+
variables = ws.variables().list(parent=_ws_path(base)).execute().get("variable", []) or []
|
|
725
|
+
|
|
726
|
+
if not variable_id:
|
|
727
|
+
if any(v.get("name") == name for v in variables):
|
|
728
|
+
return {"error": f"A variable named '{name}' already exists in workspace '{ws_name}'; update it with its variable_id instead."}
|
|
729
|
+
body: dict[str, Any] = {
|
|
730
|
+
"name": name,
|
|
731
|
+
"type": variable_type,
|
|
732
|
+
"parameter": _merge_parameters(shortcut, list(parameters or [])),
|
|
733
|
+
}
|
|
734
|
+
if notes:
|
|
735
|
+
body["notes"] = notes
|
|
736
|
+
warnings.append(f"Tags and triggers use it as {{{{{name}}}}}.")
|
|
737
|
+
return _store(operation, "gtm_variable", ws_id, {**base, "variable": body}, warnings=warnings)
|
|
738
|
+
|
|
739
|
+
var_path = f"{_ws_path(base)}/variables/{variable_id}"
|
|
740
|
+
existing = ws.variables().get(path=var_path).execute()
|
|
741
|
+
if existing.get("type") == CUSTOM_JAVASCRIPT and not config.gtm.allow_custom_html:
|
|
742
|
+
return _custom_html_refusal("editing a Custom JavaScript variable")
|
|
743
|
+
if (data_layer_key or default_value is not None) and existing.get("type") != "v":
|
|
744
|
+
return {"error": "data_layer_key and default_value only apply to Data Layer Variables"}
|
|
745
|
+
patch: dict[str, Any] = {}
|
|
746
|
+
if name and name != existing.get("name"):
|
|
747
|
+
if any(v.get("name") == name for v in variables):
|
|
748
|
+
return {"error": f"A variable named '{name}' already exists in workspace '{ws_name}'."}
|
|
749
|
+
users = _variable_references(ws, _ws_path(base), existing.get("name", ""), skip_variable_id=str(variable_id))
|
|
750
|
+
if users:
|
|
751
|
+
return {
|
|
752
|
+
"error": (
|
|
753
|
+
f"Variable '{existing.get('name')}' is referenced by {len(users)} tag(s), "
|
|
754
|
+
"trigger(s) or variable(s), and renaming it through the API would break those "
|
|
755
|
+
"{{…}} references. Rename it in the GTM UI, which updates them."
|
|
756
|
+
),
|
|
757
|
+
"referenced_by": users,
|
|
758
|
+
}
|
|
759
|
+
patch["name"] = name
|
|
760
|
+
updates = shortcut + list(parameters or [])
|
|
761
|
+
if updates:
|
|
762
|
+
patch["parameter"] = _merge_parameters(existing.get("parameter", []), updates)
|
|
763
|
+
if notes is not None:
|
|
764
|
+
patch["notes"] = notes
|
|
765
|
+
patch = {k: v for k, v in patch.items() if existing.get(k) != v}
|
|
766
|
+
if not patch:
|
|
767
|
+
return {"error": "No changes: every field passed already matches the variable."}
|
|
768
|
+
|
|
769
|
+
changes = {
|
|
770
|
+
**base,
|
|
771
|
+
"variable_id": str(variable_id),
|
|
772
|
+
"variable_name": existing.get("name"),
|
|
773
|
+
"variable_type": existing.get("type"),
|
|
774
|
+
"fingerprint": existing.get("fingerprint"),
|
|
775
|
+
"patch": patch,
|
|
776
|
+
"before": {k: existing.get(k) for k in patch},
|
|
777
|
+
}
|
|
778
|
+
warnings.append("Every tag and trigger that uses this variable picks up the new value.")
|
|
779
|
+
return _store(operation, "gtm_variable", variable_id, changes, warnings=warnings)
|
|
780
|
+
|
|
781
|
+
|
|
613
782
|
def draft_delete_gtm_entity(
|
|
614
783
|
config: AdLoopConfig,
|
|
615
784
|
*,
|
|
@@ -619,10 +788,10 @@ def draft_delete_gtm_entity(
|
|
|
619
788
|
entity_id: str,
|
|
620
789
|
workspace_id: str = "",
|
|
621
790
|
) -> dict:
|
|
622
|
-
"""Delete a workspace tag
|
|
791
|
+
"""Delete a workspace tag, trigger or variable."""
|
|
623
792
|
_require_writes(config)
|
|
624
|
-
if entity_type not in ("tag", "trigger"):
|
|
625
|
-
return {"error": "entity_type must be 'tag' or '
|
|
793
|
+
if entity_type not in ("tag", "trigger", "variable"):
|
|
794
|
+
return {"error": "entity_type must be 'tag', 'trigger' or 'variable'"}
|
|
626
795
|
operation = f"gtm_delete_{entity_type}"
|
|
627
796
|
blocked = _blocked(operation, config)
|
|
628
797
|
if blocked:
|
|
@@ -639,9 +808,22 @@ def draft_delete_gtm_entity(
|
|
|
639
808
|
"workspace_name": ws_name,
|
|
640
809
|
}
|
|
641
810
|
ws = _workspaces(client)
|
|
642
|
-
api = ws
|
|
811
|
+
api = getattr(ws, f"{entity_type}s")()
|
|
643
812
|
existing = api.get(path=f"{_ws_path(base)}/{entity_type}s/{entity_id}").execute()
|
|
644
813
|
|
|
814
|
+
if entity_type == "variable":
|
|
815
|
+
users = _variable_references(ws, _ws_path(base), existing.get("name", ""), skip_variable_id=str(entity_id))
|
|
816
|
+
if users:
|
|
817
|
+
return {
|
|
818
|
+
"error": (
|
|
819
|
+
f"Variable '{existing.get('name')}' is referenced by {len(users)} "
|
|
820
|
+
"tag(s), trigger(s) or variable(s); deleting it would break "
|
|
821
|
+
"them. Remove the {{" + str(existing.get("name")) + "}} "
|
|
822
|
+
"references first."
|
|
823
|
+
),
|
|
824
|
+
"referenced_by": users,
|
|
825
|
+
}
|
|
826
|
+
|
|
645
827
|
if entity_type == "trigger":
|
|
646
828
|
# GTM refuses to delete a referenced trigger; say which tags up front
|
|
647
829
|
# instead of letting apply fail with a bare 400.
|
|
@@ -754,13 +936,19 @@ def draft_publish_gtm_workspace(
|
|
|
754
936
|
|
|
755
937
|
|
|
756
938
|
def _custom_html_in_diff(diff: dict) -> list[dict]:
|
|
757
|
-
"""Custom HTML tags
|
|
758
|
-
|
|
939
|
+
"""Custom HTML tags and Custom JavaScript variables a version change would
|
|
940
|
+
add or change (removals are fine)."""
|
|
941
|
+
tags, variables = diff.get("tags", {}), diff.get("variables", {})
|
|
759
942
|
return [
|
|
760
943
|
{"tag_id": t.get("id"), "name": t.get("name"), "change_status": status}
|
|
761
944
|
for status in ("added", "changed")
|
|
762
945
|
for t in tags.get(status, [])
|
|
763
946
|
if t.get("type") == CUSTOM_HTML
|
|
947
|
+
] + [
|
|
948
|
+
{"variable_id": v.get("id"), "name": v.get("name"), "change_status": status}
|
|
949
|
+
for status in ("added", "changed")
|
|
950
|
+
for v in variables.get(status, [])
|
|
951
|
+
if v.get("type") == CUSTOM_JAVASCRIPT
|
|
764
952
|
]
|
|
765
953
|
|
|
766
954
|
|
|
@@ -945,13 +1133,17 @@ def _check_target_unchanged(client, plan: ChangePlan) -> dict:
|
|
|
945
1133
|
return target
|
|
946
1134
|
|
|
947
1135
|
|
|
1136
|
+
def _entity_kind(plan: ChangePlan) -> str:
|
|
1137
|
+
return plan.entity_type.replace("gtm_", "")
|
|
1138
|
+
|
|
1139
|
+
|
|
948
1140
|
def _entity_api(client, plan: ChangePlan):
|
|
949
1141
|
ws = _workspaces(client)
|
|
950
|
-
return ws
|
|
1142
|
+
return getattr(ws, f"{_entity_kind(plan)}s")()
|
|
951
1143
|
|
|
952
1144
|
|
|
953
1145
|
def _entity_path(plan: ChangePlan) -> str:
|
|
954
|
-
kind =
|
|
1146
|
+
kind = _entity_kind(plan)
|
|
955
1147
|
return f"{_ws_path(plan.changes)}/{kind}s/{plan.changes[f'{kind}_id']}"
|
|
956
1148
|
|
|
957
1149
|
|
|
@@ -974,6 +1166,10 @@ def _check_gates(config: AdLoopConfig, plan: ChangePlan) -> None:
|
|
|
974
1166
|
elif op == "gtm_update_tag":
|
|
975
1167
|
html = (changes.get("tag_type") == CUSTOM_HTML
|
|
976
1168
|
and changes["patch"] != {"paused": True})
|
|
1169
|
+
elif op == "gtm_create_variable":
|
|
1170
|
+
html = changes["variable"].get("type") == CUSTOM_JAVASCRIPT
|
|
1171
|
+
elif op == "gtm_update_variable":
|
|
1172
|
+
html = changes.get("variable_type") == CUSTOM_JAVASCRIPT
|
|
977
1173
|
else:
|
|
978
1174
|
html = op == "gtm_create_tag" and changes["tag"].get("type") == CUSTOM_HTML
|
|
979
1175
|
if html:
|
|
@@ -997,7 +1193,7 @@ def preflight(config: AdLoopConfig, plan: ChangePlan) -> dict:
|
|
|
997
1193
|
changes = plan.changes
|
|
998
1194
|
checks: dict[str, Any] = {"workspace": changes.get("workspace_name")}
|
|
999
1195
|
try:
|
|
1000
|
-
if plan.operation in ("gtm_create_tag", "gtm_create_trigger"):
|
|
1196
|
+
if plan.operation in ("gtm_create_tag", "gtm_create_trigger", "gtm_create_variable"):
|
|
1001
1197
|
_workspaces(client).get(path=_ws_path(changes)).execute()
|
|
1002
1198
|
checks["workspace_exists"] = True
|
|
1003
1199
|
elif plan.operation == "gtm_publish_workspace":
|
|
@@ -1082,7 +1278,15 @@ def _apply(config: AdLoopConfig, plan: ChangePlan) -> dict:
|
|
|
1082
1278
|
"type": resp.get("type"), "workspace_id": changes["workspace_id"],
|
|
1083
1279
|
"published": False}
|
|
1084
1280
|
|
|
1085
|
-
if op
|
|
1281
|
+
if op == "gtm_create_variable":
|
|
1282
|
+
resp = ws.variables().create(
|
|
1283
|
+
parent=_ws_path(changes), body=changes["variable"]
|
|
1284
|
+
).execute()
|
|
1285
|
+
return {"variable_id": resp.get("variableId"), "name": resp.get("name"),
|
|
1286
|
+
"type": resp.get("type"), "workspace_id": changes["workspace_id"],
|
|
1287
|
+
"published": False}
|
|
1288
|
+
|
|
1289
|
+
if op in ("gtm_update_tag", "gtm_update_trigger", "gtm_update_variable"):
|
|
1086
1290
|
api = _entity_api(client, plan)
|
|
1087
1291
|
path = _entity_path(plan)
|
|
1088
1292
|
current = _check_fingerprint(api, path, changes.get("fingerprint"),
|
|
@@ -1094,18 +1298,18 @@ def _apply(config: AdLoopConfig, plan: ChangePlan) -> dict:
|
|
|
1094
1298
|
body.update(copy.deepcopy(changes["patch"]))
|
|
1095
1299
|
resp = api.update(path=path, body=body,
|
|
1096
1300
|
fingerprint=current.get("fingerprint")).execute()
|
|
1097
|
-
kind =
|
|
1301
|
+
kind = _entity_kind(plan)
|
|
1098
1302
|
return {f"{kind}_id": resp.get(f"{kind}Id"), "name": resp.get("name"),
|
|
1099
1303
|
"updated_fields": sorted(changes["patch"]),
|
|
1100
1304
|
"workspace_id": changes["workspace_id"], "published": False}
|
|
1101
1305
|
|
|
1102
|
-
if op in ("gtm_delete_tag", "gtm_delete_trigger"):
|
|
1306
|
+
if op in ("gtm_delete_tag", "gtm_delete_trigger", "gtm_delete_variable"):
|
|
1103
1307
|
api = _entity_api(client, plan)
|
|
1104
1308
|
path = _entity_path(plan)
|
|
1105
1309
|
_check_fingerprint(api, path, changes.get("fingerprint"),
|
|
1106
1310
|
plan.entity_type.replace("gtm_", ""))
|
|
1107
1311
|
api.delete(path=path).execute()
|
|
1108
|
-
kind =
|
|
1312
|
+
kind = _entity_kind(plan)
|
|
1109
1313
|
return {f"deleted_{kind}_id": changes[f"{kind}_id"],
|
|
1110
1314
|
"workspace_id": changes["workspace_id"], "published": False}
|
|
1111
1315
|
|
|
@@ -131,14 +131,16 @@ These tools call both APIs internally and return unified results with computed `
|
|
|
131
131
|
|------|-------------|----------------|
|
|
132
132
|
| `draft_gtm_tag` | Create a tag (no `tag_id`) or update one (`tag_id`) in a workspace. On update, `parameters` merge by key and every field you don't pass is preserved | `name`, `tag_type` (create only: `googtag`, `gaawe`, `awct`, `awcc`, `awud`, `sp`, `gclidw`, `flc`, `fls`, `img`, `html`, `cvt_<id>`), `parameters`, `firing_trigger_ids`, `blocking_trigger_ids`, `paused` |
|
|
133
133
|
| `draft_gtm_trigger` | Create a trigger (no `trigger_id`) or update one | `name`, `trigger_type` (camelCase API values: `pageview`, `domReady`, `click`, `linkClick`, `formSubmission`, `customEvent`, `elementVisibility`, …), `custom_event_name`, `filters` |
|
|
134
|
-
| `
|
|
134
|
+
| `draft_gtm_variable` | Create a variable (no `variable_id`) or update one. Data Layer Variables take `data_layer_key` (e.g. `ecommerce.value`); renaming a variable that tags or triggers use as `{{name}}` is refused | `name`, `variable_type` (`v` Data Layer, `c` Constant, `u` URL, `k` cookie, `j` JS global, `aev`, `d`, `smm`, `remm`, `jsm` gated, `cvt_<id>`), `data_layer_key`, `default_value`, `parameters` |
|
|
135
|
+
| `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Referenced triggers and variables are refused with the references listed | `entity_type` (`tag`/`trigger`/`variable`), `entity_id` |
|
|
135
136
|
| `draft_publish_gtm_workspace` | Publish a workspace LIVE. Preview lists every pending change — including other people's UI edits | `version_name`, `version_notes`, `workspace_id` |
|
|
136
137
|
| `draft_rollback_gtm_version` | Republish an older container version LIVE. Preview is the diff from the live version to the target; destructive, double confirmation | `version_id` |
|
|
137
138
|
|
|
138
139
|
**GTM write rules:**
|
|
139
140
|
- Writes are off unless the user sets `gtm.write_enabled: true`; enabling it asks for re-consent with the Tag Manager edit + publish scopes. The Google account also needs Edit (and Publish) permission on the container.
|
|
140
141
|
- Tag/trigger drafts only change a **workspace**. Nothing changes on the site until `draft_publish_gtm_workspace` is applied — say so when presenting a tag preview.
|
|
141
|
-
- **Custom HTML (`html`) is gated** by `gtm.allow_custom_html`: creating/editing one, or publishing a workspace that adds/changes one, is refused while it is off. Prefer a built-in template. Pausing or deleting an HTML tag is always allowed.
|
|
142
|
+
- **Custom HTML (`html`) is gated** by `gtm.allow_custom_html`: creating/editing one, or publishing a workspace that adds/changes one, is refused while it is off. Prefer a built-in template. Pausing or deleting an HTML tag is always allowed. Custom JavaScript variables (`jsm`) are gated the same way, including when a publish or rollback would add or change one. A custom template (`cvt_…`) runs sandboxed code and is not covered; review it in the GTM UI.
|
|
143
|
+
- **GA4 purchase events:** read the purchase `dataLayer.push` in the codebase, create one Data Layer Variable per value the event needs (`draft_gtm_variable` with `data_layer_key` such as `ecommerce.transaction_id`, `ecommerce.value`, `ecommerce.currency`, `ecommerce.items`), a `customEvent` trigger for the `purchase` event, then a `gaawe` tag that sends them as `{{DLV - …}}` parameters. Check `list_gtm_variables` first: reuse a variable that reads the same key instead of creating a duplicate.
|
|
142
144
|
- Always read before writing: `list_gtm_tags` / `list_gtm_triggers` for IDs, `get_gtm_workspace_diff` before publishing. Present the publish preview's `pending_changes` in full — the user is approving all of them, not just the ones AdLoop drafted.
|
|
143
145
|
- Updates and deletes pin the entity fingerprint and publish pins the workspace state; if someone edits the container after the preview, apply refuses. Re-draft rather than retrying.
|
|
144
146
|
- The dry run for a publish runs a Tag Manager quick preview, so compiler errors show up before a version exists. The apply result names the version it replaced (`previous_live_version_id`) — that is the rollback, and if publishing a created version failed, the error carries both ids.
|
|
@@ -268,7 +270,7 @@ For images that have no public URL: a file on the user's computer, or an image t
|
|
|
268
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) |
|
|
269
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` |
|
|
270
272
|
| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` |
|
|
271
|
-
| `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. |
|
|
272
274
|
| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
|
|
273
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 |
|
|
274
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`) |
|
|
@@ -425,7 +427,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
|
|
|
425
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:
|
|
426
428
|
- Each pin reduces Google's ability to optimize headline order, which usually drops ad strength one notch.
|
|
427
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.
|
|
428
|
-
- 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.
|
|
429
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).
|
|
430
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.
|
|
431
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.
|
|
@@ -574,7 +576,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
574
576
|
|
|
575
577
|
1. Call the appropriate read tool to confirm the entity exists and get its current status
|
|
576
578
|
2. Call `pause_entity` or `enable_entity` (Google) or `pause_reddit_entity` / `enable_reddit_entity` (Reddit) with the entity type and ID
|
|
577
|
-
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
|
|
578
580
|
4. Wait for confirmation
|
|
579
581
|
|
|
580
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");
|
|
@@ -2547,7 +2547,79 @@ def draft_gtm_trigger(
|
|
|
2547
2547
|
)
|
|
2548
2548
|
|
|
2549
2549
|
|
|
2550
|
-
@_tool(title="Draft
|
|
2550
|
+
@_tool(title="Draft a Tag Manager variable", annotations=_WRITE, tags={"gtm"})
|
|
2551
|
+
@_safe
|
|
2552
|
+
def draft_gtm_variable(
|
|
2553
|
+
name: str = "",
|
|
2554
|
+
variable_type: str = "",
|
|
2555
|
+
data_layer_key: str = "",
|
|
2556
|
+
default_value: str | None = None,
|
|
2557
|
+
parameters: _DictListOpt = None,
|
|
2558
|
+
notes: str | None = None,
|
|
2559
|
+
variable_id: str = "",
|
|
2560
|
+
workspace_id: str = "",
|
|
2561
|
+
gtm_account_id: str = "",
|
|
2562
|
+
gtm_container_id: str = "",
|
|
2563
|
+
) -> dict:
|
|
2564
|
+
"""Draft creating or updating a Tag Manager variable: returns a PREVIEW.
|
|
2565
|
+
|
|
2566
|
+
Without variable_id this creates a variable in a workspace; with
|
|
2567
|
+
variable_id it updates the existing one. Data Layer Variables, the usual
|
|
2568
|
+
source for GA4 ecommerce values, take data_layer_key (e.g.
|
|
2569
|
+
"ecommerce.value") instead of raw parameters. Tags and triggers use a
|
|
2570
|
+
variable as {{name}}; renaming one they reference is refused, because
|
|
2571
|
+
the references would break (the GTM UI renames safely). Custom
|
|
2572
|
+
JavaScript variables run code on the site and are refused unless
|
|
2573
|
+
gtm.allow_custom_html is set. The edit lands in the workspace and
|
|
2574
|
+
changes nothing on the site until the workspace is published. Refused
|
|
2575
|
+
unless gtm.write_enabled is set.
|
|
2576
|
+
|
|
2577
|
+
Args:
|
|
2578
|
+
name: Variable name, 1-200 characters without curly braces.
|
|
2579
|
+
Required when creating a variable.
|
|
2580
|
+
variable_type: Required when creating, fixed afterwards: v (Data
|
|
2581
|
+
Layer Variable), c (Constant), u (URL), k (1st-party cookie), j
|
|
2582
|
+
(JavaScript Variable, reads a global), aev (Auto-Event
|
|
2583
|
+
Variable), d (DOM element), f (HTTP referrer), e (Custom event),
|
|
2584
|
+
smm (Lookup table), remm (RegEx table), jsm (Custom JavaScript,
|
|
2585
|
+
gated) or cvt_<id> for a custom template.
|
|
2586
|
+
data_layer_key: For Data Layer Variables: the dataLayer key to read,
|
|
2587
|
+
dot notation for nested values, e.g. "ecommerce.transaction_id".
|
|
2588
|
+
default_value: For Data Layer Variables: the value when the key is
|
|
2589
|
+
missing. None sets no default.
|
|
2590
|
+
parameters: GTM parameter dicts with at least "type" and "key" for
|
|
2591
|
+
the other types, e.g. a Constant's [{"type": "TEMPLATE", "key":
|
|
2592
|
+
"value", "value": "G-ABC123"}]. On update they merge by key.
|
|
2593
|
+
notes: Free-form note stored on the variable. None keeps the current
|
|
2594
|
+
note.
|
|
2595
|
+
variable_id: Existing variable ID to update (see list_gtm_variables).
|
|
2596
|
+
Empty creates a new variable.
|
|
2597
|
+
workspace_id: Workspace to edit (see list_gtm_workspaces). Empty uses
|
|
2598
|
+
the Default Workspace, or the only workspace there is.
|
|
2599
|
+
gtm_account_id: Numeric GTM account ID (see list_gtm_accounts). Empty
|
|
2600
|
+
uses gtm.account_id from the config.
|
|
2601
|
+
gtm_container_id: Numeric GTM container ID (see list_gtm_containers).
|
|
2602
|
+
Empty uses gtm.container_id from the config.
|
|
2603
|
+
"""
|
|
2604
|
+
from adloop.gtm.write import draft_gtm_variable as _impl
|
|
2605
|
+
|
|
2606
|
+
gtm_account_id, gtm_container_id = _gtm_defaults(gtm_account_id, gtm_container_id)
|
|
2607
|
+
return _impl(
|
|
2608
|
+
current_config(),
|
|
2609
|
+
account_id=gtm_account_id,
|
|
2610
|
+
container_id=gtm_container_id,
|
|
2611
|
+
variable_id=variable_id,
|
|
2612
|
+
workspace_id=workspace_id,
|
|
2613
|
+
name=name,
|
|
2614
|
+
variable_type=variable_type,
|
|
2615
|
+
data_layer_key=data_layer_key,
|
|
2616
|
+
default_value=default_value,
|
|
2617
|
+
parameters=parameters,
|
|
2618
|
+
notes=notes,
|
|
2619
|
+
)
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
@_tool(title="Draft deleting a Tag Manager tag, trigger or variable", annotations=_DESTRUCTIVE, tags={"gtm"})
|
|
2551
2623
|
@_safe
|
|
2552
2624
|
def draft_delete_gtm_entity(
|
|
2553
2625
|
entity_type: str,
|
|
@@ -2556,18 +2628,19 @@ def draft_delete_gtm_entity(
|
|
|
2556
2628
|
gtm_account_id: str = "",
|
|
2557
2629
|
gtm_container_id: str = "",
|
|
2558
2630
|
) -> dict:
|
|
2559
|
-
"""Draft deleting a Tag Manager tag or
|
|
2631
|
+
"""Draft deleting a Tag Manager tag, trigger or variable — returns a PREVIEW.
|
|
2560
2632
|
|
|
2561
2633
|
The entity is removed from a workspace and stays on the site until the
|
|
2562
|
-
workspace is published. A trigger that any tag still references
|
|
2563
|
-
|
|
2634
|
+
workspace is published. A trigger that any tag still references, or a
|
|
2635
|
+
variable that a tag, trigger or other variable uses as {{name}}, is
|
|
2636
|
+
refused up front, with the references named. Refused unless
|
|
2564
2637
|
gtm.write_enabled is set. Returns the entity that would be deleted, the
|
|
2565
2638
|
resolved workspace and a plan_id.
|
|
2566
2639
|
|
|
2567
2640
|
Args:
|
|
2568
|
-
entity_type: "tag" or "
|
|
2569
|
-
entity_id: Numeric ID of the entity (see list_gtm_tags
|
|
2570
|
-
list_gtm_triggers).
|
|
2641
|
+
entity_type: "tag", "trigger" or "variable".
|
|
2642
|
+
entity_id: Numeric ID of the entity (see list_gtm_tags,
|
|
2643
|
+
list_gtm_triggers and list_gtm_variables).
|
|
2571
2644
|
workspace_id: Workspace to edit (see list_gtm_workspaces). Empty uses
|
|
2572
2645
|
the Default Workspace, or the only workspace there is.
|
|
2573
2646
|
gtm_account_id: Numeric GTM account ID (see list_gtm_accounts). Empty
|
|
@@ -3333,10 +3406,11 @@ def draft_responsive_search_ad(
|
|
|
3333
3406
|
|
|
3334
3407
|
The preview shows exactly what will be created; confirm_and_apply executes
|
|
3335
3408
|
the returned plan_id. The ad is created PAUSED. final_url is checked for
|
|
3336
|
-
reachability first and an unreachable URL is refused.
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
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
|
|
3340
3414
|
headlines or 3 descriptions.
|
|
3341
3415
|
|
|
3342
3416
|
Args:
|
|
@@ -3410,15 +3484,15 @@ def update_responsive_search_ad(
|
|
|
3410
3484
|
uses the configured default account.
|
|
3411
3485
|
headlines: LIST-REPLACE: None or [] means no change; a non-empty list
|
|
3412
3486
|
fully swaps in for the existing headlines. Google's RSA constraints
|
|
3413
|
-
apply: 3-15 headlines, max 30 chars each,
|
|
3487
|
+
apply: 3-15 headlines, max 30 chars each, every pin position fillable.
|
|
3414
3488
|
Each entry is a plain string (unpinned) or {"text": "...",
|
|
3415
3489
|
"pinned_field": "HEADLINE_1"} (HEADLINE_1-3). Insertion tags such as
|
|
3416
3490
|
{KeyWord:Clogged Drain Service} count as their default text, which
|
|
3417
3491
|
is required.
|
|
3418
3492
|
descriptions: LIST-REPLACE: None or [] means no change; a non-empty list
|
|
3419
|
-
fully swaps in. 2-4 descriptions, max 90 chars each,
|
|
3420
|
-
|
|
3421
|
-
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.
|
|
3422
3496
|
final_url: Empty means no change; non-empty replaces the final URL and
|
|
3423
3497
|
is checked for reachability.
|
|
3424
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
|