adloop 0.29.2__tar.gz → 0.31.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.29.2 → adloop-0.31.0}/PKG-INFO +5 -2
- {adloop-0.29.2 → adloop-0.31.0}/README.md +4 -1
- {adloop-0.29.2 → adloop-0.31.0}/pyproject.toml +1 -1
- {adloop-0.29.2 → adloop-0.31.0}/pyproject.toml.orig +1 -1
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/shopping.py +128 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gtm/write.py +233 -29
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/merchant/client.py +2 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/merchant/read.py +176 -21
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/adloop.md +9 -3
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/server.py +149 -7
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/__main__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/auth.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/cli.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/config.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/crossref.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/runtime.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/tracking.py +0 -0
- {adloop-0.29.2 → adloop-0.31.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.29.2 → adloop-0.31.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.31.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
|
|
@@ -136,6 +136,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
136
136
|
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
|
|
137
137
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
138
138
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
139
|
+
| `get_shopping_product_performance` | Spend, conversions, CPA and ROAS per product across Shopping and Performance Max, plus products that spent without converting |
|
|
139
140
|
| `get_change_history` | What changed before the drop: who changed what and when, through which client (UI, API, scripts, auto-applied recommendations), last 30 days |
|
|
140
141
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
141
142
|
| `get_audience_lists` | The account's remarketing and customer lists (size, eligibility, open/closed), custom and combined segments |
|
|
@@ -205,7 +206,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
|
|
|
205
206
|
|------|-------------|
|
|
206
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. |
|
|
207
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. |
|
|
208
|
-
| `
|
|
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. |
|
|
209
211
|
| `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
|
|
210
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. |
|
|
211
213
|
|
|
@@ -239,6 +241,7 @@ The Google account also needs **Edit** permission on the container (and **Publis
|
|
|
239
241
|
|------|-------------|
|
|
240
242
|
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
241
243
|
| `get_merchant_feed_health` | Feed health — approved/pending/disapproved counts per reporting context, top product issues with docs, account-level issues. Disapprovals silently starve Shopping/PMax. |
|
|
244
|
+
| `get_merchant_product_issues` | The products behind the feed-health counts: which are disapproved, limited or pending, and each issue with its attribute and affected countries |
|
|
242
245
|
|
|
243
246
|
> **Setup for Merchant Center tools** — Enable the **Merchant API** in your GCP project (the Content API for Shopping is deprecated). The Merchant API has no read-only scope; AdLoop uses it strictly read-only. Upgrading OAuth users re-authorize once.
|
|
244
247
|
|
|
@@ -108,6 +108,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
108
108
|
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
|
|
109
109
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
|
|
110
110
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
111
|
+
| `get_shopping_product_performance` | Spend, conversions, CPA and ROAS per product across Shopping and Performance Max, plus products that spent without converting |
|
|
111
112
|
| `get_change_history` | What changed before the drop: who changed what and when, through which client (UI, API, scripts, auto-applied recommendations), last 30 days |
|
|
112
113
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
113
114
|
| `get_audience_lists` | The account's remarketing and customer lists (size, eligibility, open/closed), custom and combined segments |
|
|
@@ -177,7 +178,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
|
|
|
177
178
|
|------|-------------|
|
|
178
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. |
|
|
179
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. |
|
|
180
|
-
| `
|
|
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. |
|
|
181
183
|
| `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
|
|
182
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. |
|
|
183
185
|
|
|
@@ -211,6 +213,7 @@ The Google account also needs **Edit** permission on the container (and **Publis
|
|
|
211
213
|
|------|-------------|
|
|
212
214
|
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
213
215
|
| `get_merchant_feed_health` | Feed health — approved/pending/disapproved counts per reporting context, top product issues with docs, account-level issues. Disapprovals silently starve Shopping/PMax. |
|
|
216
|
+
| `get_merchant_product_issues` | The products behind the feed-health counts: which are disapproved, limited or pending, and each issue with its attribute and affected countries |
|
|
214
217
|
|
|
215
218
|
> **Setup for Merchant Center tools** — Enable the **Merchant API** in your GCP project (the Content API for Shopping is deprecated). The Merchant API has no read-only scope; AdLoop uses it strictly read-only. Upgrading OAuth users re-authorize once.
|
|
216
219
|
|
|
@@ -282,3 +282,131 @@ def apply_create_shopping_campaign(client: object, cid: str, changes: dict) -> d
|
|
|
282
282
|
"with enable_entity(entity_type='campaign')."
|
|
283
283
|
),
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Product performance (Shopping and Performance Max)
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
_MAX_PRODUCT_ROWS = 10000
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_shopping_product_performance(
|
|
295
|
+
config: AdLoopConfig,
|
|
296
|
+
*,
|
|
297
|
+
customer_id: str = "",
|
|
298
|
+
date_range_start: str = "",
|
|
299
|
+
date_range_end: str = "",
|
|
300
|
+
campaign_id: str = "",
|
|
301
|
+
limit: int = 50,
|
|
302
|
+
) -> dict:
|
|
303
|
+
"""Spend, clicks, conversions and value per product across Shopping and PMax.
|
|
304
|
+
|
|
305
|
+
Reads shopping_performance_view and sums each product (item id) over
|
|
306
|
+
the campaigns it ran in. Returns the top products by spend with CPA and
|
|
307
|
+
ROAS, plus the products that spent without converting.
|
|
308
|
+
"""
|
|
309
|
+
from adloop.ads.currency import get_currency_code
|
|
310
|
+
from adloop.ads.gaql import execute_query
|
|
311
|
+
from adloop.ads.read import _date_clause
|
|
312
|
+
|
|
313
|
+
campaign_id = str(campaign_id or "").strip()
|
|
314
|
+
if campaign_id and not campaign_id.isdigit():
|
|
315
|
+
return {"error": "campaign_id must be a numeric Google Ads id"}
|
|
316
|
+
limit = max(1, min(int(limit or 50), 200))
|
|
317
|
+
campaign_filter = f"AND campaign.id = {campaign_id} " if campaign_id else ""
|
|
318
|
+
rows = execute_query(
|
|
319
|
+
config,
|
|
320
|
+
customer_id,
|
|
321
|
+
"SELECT segments.product_item_id, segments.product_title, segments.product_brand, campaign.id, "
|
|
322
|
+
"campaign.name, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, "
|
|
323
|
+
"metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM shopping_performance_view "
|
|
324
|
+
f"WHERE metrics.impressions > 0 {campaign_filter}{_date_clause(date_range_start, date_range_end)} "
|
|
325
|
+
f"ORDER BY metrics.cost_micros DESC LIMIT {_MAX_PRODUCT_ROWS}",
|
|
326
|
+
)
|
|
327
|
+
currency = get_currency_code(config, customer_id)
|
|
328
|
+
|
|
329
|
+
products: dict[str, dict] = {}
|
|
330
|
+
for row in rows:
|
|
331
|
+
item = str(row.get("segments.product_item_id") or "").strip()
|
|
332
|
+
if not item:
|
|
333
|
+
continue
|
|
334
|
+
product = products.setdefault(item, {
|
|
335
|
+
"item_id": item,
|
|
336
|
+
"title": row.get("segments.product_title") or "",
|
|
337
|
+
"brand": row.get("segments.product_brand") or "",
|
|
338
|
+
"campaigns": set(),
|
|
339
|
+
"channels": set(),
|
|
340
|
+
"impressions": 0, "clicks": 0, "cost_micros": 0, "conversions": 0.0, "conversions_value": 0.0,
|
|
341
|
+
})
|
|
342
|
+
product["campaigns"].add(row.get("campaign.name") or str(row.get("campaign.id")))
|
|
343
|
+
product["channels"].add(str(row.get("campaign.advertising_channel_type") or ""))
|
|
344
|
+
product["impressions"] += int(row.get("metrics.impressions") or 0)
|
|
345
|
+
product["clicks"] += int(row.get("metrics.clicks") or 0)
|
|
346
|
+
product["cost_micros"] += int(row.get("metrics.cost_micros") or 0)
|
|
347
|
+
product["conversions"] += float(row.get("metrics.conversions") or 0)
|
|
348
|
+
product["conversions_value"] += float(row.get("metrics.conversions_value") or 0)
|
|
349
|
+
|
|
350
|
+
out = []
|
|
351
|
+
for p in products.values():
|
|
352
|
+
cost = p["cost_micros"] / 1_000_000
|
|
353
|
+
out.append({
|
|
354
|
+
"item_id": p["item_id"],
|
|
355
|
+
"title": p["title"],
|
|
356
|
+
"brand": p["brand"],
|
|
357
|
+
"campaigns": sorted(p["campaigns"]),
|
|
358
|
+
"channels": sorted(c for c in p["channels"] if c),
|
|
359
|
+
"impressions": p["impressions"],
|
|
360
|
+
"clicks": p["clicks"],
|
|
361
|
+
"cost": round(cost, 2),
|
|
362
|
+
"conversions": round(p["conversions"], 2),
|
|
363
|
+
"conversions_value": round(p["conversions_value"], 2),
|
|
364
|
+
"cpa": round(cost / p["conversions"], 2) if p["conversions"] else None,
|
|
365
|
+
"roas": round(p["conversions_value"] / cost, 2) if cost and p["conversions_value"] else None,
|
|
366
|
+
})
|
|
367
|
+
out.sort(key=lambda p: p["cost"], reverse=True)
|
|
368
|
+
|
|
369
|
+
total_cost = round(sum(p["cost"] for p in out), 2)
|
|
370
|
+
total_value = round(sum(p["conversions_value"] for p in out), 2)
|
|
371
|
+
totals = {
|
|
372
|
+
"products": len(out),
|
|
373
|
+
"cost": total_cost,
|
|
374
|
+
"clicks": sum(p["clicks"] for p in out),
|
|
375
|
+
"conversions": round(sum(p["conversions"] for p in out), 2),
|
|
376
|
+
"conversions_value": total_value,
|
|
377
|
+
"roas": round(total_value / total_cost, 2) if total_cost and total_value else None,
|
|
378
|
+
"currency": currency,
|
|
379
|
+
}
|
|
380
|
+
# Spenders without a conversion, from the products that took at least
|
|
381
|
+
# 1% of the spend (a few cents on one product is noise).
|
|
382
|
+
threshold = max(total_cost * 0.01, 0.01)
|
|
383
|
+
no_conversions = [p for p in out if p["conversions"] == 0 and p["cost"] >= threshold]
|
|
384
|
+
insights = []
|
|
385
|
+
if not out:
|
|
386
|
+
insights.append(
|
|
387
|
+
"No Shopping or Performance Max product data in this period: the account has no product ads "
|
|
388
|
+
"running, or the campaigns did not serve."
|
|
389
|
+
)
|
|
390
|
+
if no_conversions:
|
|
391
|
+
wasted = round(sum(p["cost"] for p in no_conversions), 2)
|
|
392
|
+
insights.append(
|
|
393
|
+
f"{len(no_conversions)} product(s) spent {wasted} {currency} without a conversion. Check the "
|
|
394
|
+
"landing page and price first; in Performance Max, draft_pmax_product_exclusions can take "
|
|
395
|
+
"the worst ones out, in Shopping a lower product bid."
|
|
396
|
+
)
|
|
397
|
+
if totals["roas"] is not None:
|
|
398
|
+
weak = [p for p in out if p["roas"] is not None and p["roas"] < totals["roas"] / 2 and p["cost"] >= threshold]
|
|
399
|
+
if weak:
|
|
400
|
+
insights.append(
|
|
401
|
+
f"{len(weak)} converting product(s) return less than half the account's ROAS ({totals['roas']})."
|
|
402
|
+
)
|
|
403
|
+
if len(rows) >= _MAX_PRODUCT_ROWS:
|
|
404
|
+
insights.append("The period has more product rows than one report returns; narrow the dates or pass campaign_id.")
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
"date_range": {"start": date_range_start or "LAST_30_DAYS", "end": date_range_end or ""},
|
|
408
|
+
"totals": totals,
|
|
409
|
+
"products": out[:limit],
|
|
410
|
+
"no_conversion_spenders": no_conversions[:20],
|
|
411
|
+
"insights": insights,
|
|
412
|
+
}
|
|
@@ -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
|
|
|
@@ -22,6 +22,8 @@ _BASE = "https://merchantapi.googleapis.com"
|
|
|
22
22
|
# states its sub-API and API_ACCOUNTS is only the default.
|
|
23
23
|
API_ACCOUNTS = "accounts/v1"
|
|
24
24
|
API_ISSUE_RESOLUTION = "issueresolution/v1"
|
|
25
|
+
# Product-level reporting (MCQL queries over product_view and friends).
|
|
26
|
+
API_REPORTS = "reports/v1"
|
|
25
27
|
|
|
26
28
|
# Google blocks every Merchant API call from a GCP project that is not
|
|
27
29
|
# registered as a developer with the merchant account (401 UNAUTHENTICATED,
|