adloop 0.29.2__tar.gz → 0.30.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.30.0}/PKG-INFO +3 -1
- {adloop-0.29.2 → adloop-0.30.0}/README.md +2 -0
- {adloop-0.29.2 → adloop-0.30.0}/pyproject.toml +1 -1
- {adloop-0.29.2 → adloop-0.30.0}/pyproject.toml.orig +1 -1
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/shopping.py +128 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/merchant/client.py +2 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/merchant/read.py +176 -21
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/adloop.md +5 -1
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/server.py +69 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/__main__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ads/write.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/auth.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/cli.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/config.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/crossref.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/runtime.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/tracking.py +0 -0
- {adloop-0.29.2 → adloop-0.30.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.29.2 → adloop-0.30.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.30.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 |
|
|
@@ -239,6 +240,7 @@ The Google account also needs **Edit** permission on the container (and **Publis
|
|
|
239
240
|
|------|-------------|
|
|
240
241
|
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
241
242
|
| `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. |
|
|
243
|
+
| `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
244
|
|
|
243
245
|
> **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
246
|
|
|
@@ -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 |
|
|
@@ -211,6 +212,7 @@ The Google account also needs **Edit** permission on the container (and **Publis
|
|
|
211
212
|
|------|-------------|
|
|
212
213
|
| `list_merchant_accounts` | Discover accessible Merchant Center accounts |
|
|
213
214
|
| `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. |
|
|
215
|
+
| `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
216
|
|
|
215
217
|
> **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
218
|
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -124,27 +124,9 @@ def get_merchant_feed_health(
|
|
|
124
124
|
register_gcp,
|
|
125
125
|
)
|
|
126
126
|
|
|
127
|
-
account_id =
|
|
128
|
-
if
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
"error": (
|
|
133
|
-
"account_id is required: no Merchant Center account was "
|
|
134
|
-
"passed and no default is configured."
|
|
135
|
-
),
|
|
136
|
-
"hint": (
|
|
137
|
-
"Call list_merchant_accounts to find the numeric Merchant "
|
|
138
|
-
"Center ID and pass it as account_id, or "
|
|
139
|
-
+ default_setting_hint("merchant.account_id", "Settings → Google & accounts")
|
|
140
|
-
+ "."
|
|
141
|
-
),
|
|
142
|
-
}
|
|
143
|
-
if not account_id.isdigit():
|
|
144
|
-
return {
|
|
145
|
-
"error": "account_id must be a numeric Merchant Center ID — "
|
|
146
|
-
"call list_merchant_accounts first."
|
|
147
|
-
}
|
|
127
|
+
account_id, error = _account_or_error(config, account_id)
|
|
128
|
+
if error:
|
|
129
|
+
return error
|
|
148
130
|
|
|
149
131
|
def _fetch() -> tuple[dict, dict]:
|
|
150
132
|
return (
|
|
@@ -245,3 +227,176 @@ def get_merchant_feed_health(
|
|
|
245
227
|
"top_item_issues": top_item_issues[:15],
|
|
246
228
|
"insights": insights,
|
|
247
229
|
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Product-level issues (Reports API, product_view)
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
_STATUS_FILTERS = {
|
|
237
|
+
"disapproved": "NOT_ELIGIBLE_OR_DISAPPROVED",
|
|
238
|
+
"limited": "ELIGIBLE_LIMITED",
|
|
239
|
+
"pending": "PENDING",
|
|
240
|
+
}
|
|
241
|
+
_CLICK_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
242
|
+
_MAX_PRODUCTS = 200
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _account_or_error(config: AdLoopConfig, account_id: str) -> tuple[str, dict | None]:
|
|
246
|
+
account_id = str(account_id or "").strip() or config.merchant.account_id.strip()
|
|
247
|
+
if not account_id:
|
|
248
|
+
from adloop.runtime import default_setting_hint
|
|
249
|
+
|
|
250
|
+
return "", {
|
|
251
|
+
"error": "account_id is required: no Merchant Center account was passed and no default is configured.",
|
|
252
|
+
"hint": (
|
|
253
|
+
"Call list_merchant_accounts to find the numeric Merchant Center ID and pass it as account_id, or "
|
|
254
|
+
+ default_setting_hint("merchant.account_id", "Settings → Google & accounts")
|
|
255
|
+
+ "."
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
if not account_id.isdigit():
|
|
259
|
+
return "", {"error": "account_id must be a numeric Merchant Center ID — call list_merchant_accounts first."}
|
|
260
|
+
return account_id, None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _issue_entry(issue: dict) -> dict:
|
|
264
|
+
severity = issue.get("severity") or {}
|
|
265
|
+
contexts = [
|
|
266
|
+
{
|
|
267
|
+
"reporting_context": ctx.get("reportingContext", ""),
|
|
268
|
+
"disapproved_countries": ctx.get("disapprovedCountries", []) or [],
|
|
269
|
+
"demoted_countries": ctx.get("demotedCountries", []) or [],
|
|
270
|
+
}
|
|
271
|
+
for ctx in severity.get("severityPerReportingContext", []) or []
|
|
272
|
+
]
|
|
273
|
+
return {
|
|
274
|
+
"code": (issue.get("type") or {}).get("code", ""),
|
|
275
|
+
"attribute": (issue.get("type") or {}).get("canonicalAttribute", ""),
|
|
276
|
+
"severity": severity.get("aggregatedSeverity", ""),
|
|
277
|
+
"resolution": issue.get("resolution", ""),
|
|
278
|
+
"where": contexts,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_merchant_product_issues(
|
|
283
|
+
config: AdLoopConfig,
|
|
284
|
+
*,
|
|
285
|
+
account_id: str = "",
|
|
286
|
+
status: str = "disapproved",
|
|
287
|
+
issue_code: str = "",
|
|
288
|
+
limit: int = 50,
|
|
289
|
+
) -> dict:
|
|
290
|
+
"""The products behind the feed-health counts, with each product's issues.
|
|
291
|
+
|
|
292
|
+
Queries product_view through the Reports API: offer id, title, brand,
|
|
293
|
+
status, click potential and item issues (code, attribute, severity per
|
|
294
|
+
reporting context with the affected countries, and whether the merchant
|
|
295
|
+
has to act). Products with high click potential come first, because
|
|
296
|
+
fixing them recovers the most traffic.
|
|
297
|
+
"""
|
|
298
|
+
from adloop.merchant.client import API_REPORTS, MerchantNotRegistered, merchant_post, register_gcp
|
|
299
|
+
|
|
300
|
+
account_id, error = _account_or_error(config, account_id)
|
|
301
|
+
if error:
|
|
302
|
+
return error
|
|
303
|
+
if status not in (*_STATUS_FILTERS, "any"):
|
|
304
|
+
return {"error": "status must be disapproved (default), limited, pending or any"}
|
|
305
|
+
limit = max(1, min(int(limit or 50), _MAX_PRODUCTS))
|
|
306
|
+
issue_code = str(issue_code or "").strip()
|
|
307
|
+
|
|
308
|
+
where = f" WHERE aggregated_reporting_context_status = '{_STATUS_FILTERS[status]}'" if status in _STATUS_FILTERS else ""
|
|
309
|
+
query = (
|
|
310
|
+
"SELECT id, offer_id, title, brand, feed_label, availability, click_potential, "
|
|
311
|
+
f"aggregated_reporting_context_status, item_issues FROM product_view{where}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _fetch() -> dict:
|
|
315
|
+
return merchant_post(
|
|
316
|
+
config,
|
|
317
|
+
f"accounts/{account_id}/reports:search",
|
|
318
|
+
{"query": query, "pageSize": 1000},
|
|
319
|
+
api=API_REPORTS,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
payload = _fetch()
|
|
324
|
+
except MerchantNotRegistered:
|
|
325
|
+
try:
|
|
326
|
+
register_gcp(config, account_id)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
return _registration_failed(account_id, e)
|
|
329
|
+
try:
|
|
330
|
+
payload = _fetch()
|
|
331
|
+
except MerchantNotRegistered:
|
|
332
|
+
return _registered_retry_later(account_id)
|
|
333
|
+
|
|
334
|
+
products = []
|
|
335
|
+
by_code: dict[str, dict] = {}
|
|
336
|
+
for row in payload.get("results", []) or []:
|
|
337
|
+
view = row.get("productView") or {}
|
|
338
|
+
issues = [_issue_entry(i) for i in view.get("itemIssues", []) or []]
|
|
339
|
+
if status == "any" and not issues:
|
|
340
|
+
continue
|
|
341
|
+
if issue_code and not any(i["code"] == issue_code for i in issues):
|
|
342
|
+
continue
|
|
343
|
+
product = {
|
|
344
|
+
"offer_id": view.get("offerId", ""),
|
|
345
|
+
"product_id": view.get("id", ""),
|
|
346
|
+
"title": view.get("title", ""),
|
|
347
|
+
"brand": view.get("brand", ""),
|
|
348
|
+
"feed_label": view.get("feedLabel", ""),
|
|
349
|
+
"availability": view.get("availability", ""),
|
|
350
|
+
"status": view.get("aggregatedReportingContextStatus", ""),
|
|
351
|
+
"click_potential": view.get("clickPotential", ""),
|
|
352
|
+
"issues": issues,
|
|
353
|
+
}
|
|
354
|
+
products.append(product)
|
|
355
|
+
for issue in issues:
|
|
356
|
+
entry = by_code.setdefault(issue["code"], {
|
|
357
|
+
"code": issue["code"],
|
|
358
|
+
"attribute": issue["attribute"],
|
|
359
|
+
"severity": issue["severity"],
|
|
360
|
+
"merchant_action": issue["resolution"] == "MERCHANT_ACTION",
|
|
361
|
+
"products": 0,
|
|
362
|
+
})
|
|
363
|
+
entry["products"] += 1
|
|
364
|
+
products.sort(key=lambda p: (_CLICK_ORDER.get(p["click_potential"], 3), p["title"]))
|
|
365
|
+
summary = sorted(by_code.values(), key=lambda e: e["products"], reverse=True)
|
|
366
|
+
|
|
367
|
+
insights = []
|
|
368
|
+
if not products:
|
|
369
|
+
insights.append(
|
|
370
|
+
f"No products with status {status}." if status != "any" else "No product has an item issue."
|
|
371
|
+
)
|
|
372
|
+
high = [p for p in products if p["click_potential"] == "HIGH"]
|
|
373
|
+
if high and status == "disapproved":
|
|
374
|
+
insights.append(
|
|
375
|
+
f"{len(high)} disapproved product(s) have HIGH click potential: fixing them recovers the most traffic first."
|
|
376
|
+
)
|
|
377
|
+
if summary:
|
|
378
|
+
top = summary[0]
|
|
379
|
+
insights.append(
|
|
380
|
+
f"Most common issue: {top['code']}"
|
|
381
|
+
+ (f" on attribute {top['attribute']}" if top["attribute"] else "")
|
|
382
|
+
+ f" ({top['products']} product(s))."
|
|
383
|
+
)
|
|
384
|
+
waiting = [e["code"] for e in summary if not e["merchant_action"]]
|
|
385
|
+
if waiting:
|
|
386
|
+
insights.append(
|
|
387
|
+
"Pending Google's processing, nothing to do yet: " + ", ".join(waiting[:5]) + "."
|
|
388
|
+
)
|
|
389
|
+
if payload.get("nextPageToken"):
|
|
390
|
+
insights.append("More than 1,000 products matched; narrow with issue_code to see the rest.")
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
"account_id": account_id,
|
|
394
|
+
"status": status,
|
|
395
|
+
"products": products[:limit],
|
|
396
|
+
"shown": min(limit, len(products)),
|
|
397
|
+
"matched": len(products),
|
|
398
|
+
"issues_by_code": summary[:20],
|
|
399
|
+
"insights": insights,
|
|
400
|
+
"note": "Product data lags about 30 minutes behind feed changes.",
|
|
401
|
+
}
|
|
402
|
+
|
|
@@ -57,6 +57,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
57
57
|
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) |
|
|
58
58
|
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` |
|
|
59
59
|
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) |
|
|
60
|
+
| `get_shopping_product_performance` | Spend, clicks, conversions, CPA and ROAS per product (item id) across Shopping and PMax, plus the products that spent without converting | `date_range_start`, `date_range_end`, `campaign_id` (optional), `limit` |
|
|
60
61
|
| `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) |
|
|
61
62
|
| `get_change_history` | What changed in the account and who changed it: time, user email, client (UI, API, scripts, Editor, auto-applied recommendations), resource type, operation, changed fields, campaign/ad group. The first stop for "what changed before the drop?" (30 days of history) | `date_range_start`, `date_range_end`, `campaign_id` (optional), `resource_types` (optional), `limit` (optional, max 10000) |
|
|
62
63
|
| `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` |
|
|
@@ -172,10 +173,12 @@ These tools call both APIs internally and return unified results with computed `
|
|
|
172
173
|
|------|-------------|----------------|
|
|
173
174
|
| `list_merchant_accounts` | Discovery — which Merchant Center accounts the connected user can reach | (none) |
|
|
174
175
|
| `get_merchant_feed_health` | Shopping/PMax underperforming or dead — check feed disapprovals FIRST, before touching bids/budgets | `account_id` (numeric; from list_merchant_accounts — optional when `merchant.account_id` is configured) |
|
|
176
|
+
| `get_merchant_product_issues` | Which products are disapproved, limited or pending, and why: offer id, title, click potential, and each issue's code, attribute, affected countries and whether the merchant has to act | `account_id`, `status` (disapproved/limited/pending/any), `issue_code`, `limit` |
|
|
175
177
|
|
|
176
178
|
- **Disapproved feed items are the #1 silent PMax/Shopping killer.** When a Shopping or PMax campaign has impressions collapsing or zero delivery, check `get_merchant_feed_health` before any campaign-side diagnosis.
|
|
177
179
|
- The Merchant API has no read-only scope; AdLoop's merchant tools are read-only by design — never propose feed edits through these tools. Product-status data lags ~30 minutes — say so when the user just changed the feed.
|
|
178
180
|
- Item issues come ranked by affected products and include Google's resolution documentation links — surface the top 3 with their doc links.
|
|
181
|
+
- To name the products behind the counts, call `get_merchant_product_issues`: products with HIGH click potential come first, because fixing them recovers the most traffic. `issues_by_code` groups the products per issue; an issue whose resolution is `PENDING_PROCESSING` needs no action, Google is still reviewing. Filter with `issue_code` to list every product of one issue (e.g. all missing GTINs) for the user to fix in the feed.
|
|
179
182
|
|
|
180
183
|
### Reddit Ads Read Tools
|
|
181
184
|
|
|
@@ -465,7 +468,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
|
|
|
465
468
|
|
|
466
469
|
### When user wants a Shopping campaign
|
|
467
470
|
|
|
468
|
-
1. `list_merchant_accounts` for the Merchant Center id, then `get_merchant_feed_health`: a feed with many disapproved products needs fixing first, a campaign cannot advertise them
|
|
471
|
+
1. `list_merchant_accounts` for the Merchant Center id, then `get_merchant_feed_health`: a feed with many disapproved products needs fixing first, a campaign cannot advertise them; `get_merchant_product_issues` names those products and their issues
|
|
469
472
|
2. The Merchant Center account must be linked to the Google Ads account (Merchant Center → Settings → Apps and services); `draft_shopping_campaign` checks it and says which accounts are linked
|
|
470
473
|
3. Ask for countries if not stated. Shopping takes no language targeting and no keywords: the feed's language applies, and search terms are controlled with negative keywords after launch
|
|
471
474
|
4. Bidding: MANUAL_CPC with a modest `product_bid` to start, TARGET_ROAS once the account has conversion value history, TARGET_SPEND (Maximize clicks) to gather data quickly. `product_bid` is always required (Google sets it on the product group)
|
|
@@ -606,6 +609,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
606
609
|
- Ensure asset diversity: at least 5 headlines, 5 descriptions, 5 marketing images (landscape AND square), a logo, ideally a YouTube video. The preview's `counts_after` shows the result per type
|
|
607
610
|
- Signals: `get_asset_group_signals`, then `draft_asset_group_signals` to add search themes (what customers search for the products) or set an audience signal. Signals guide PMax; they do not restrict it
|
|
608
611
|
- Products that must not be advertised (out of stock, low margin, a brand you may not advertise): `draft_pmax_product_exclusions` with the COMPLETE list of excluded item ids, brands or custom labels. Check `get_merchant_feed_health` first: disapproved products are a feed problem, not an exclusion
|
|
612
|
+
- Which products earn and which only spend: `get_shopping_product_performance`. Its `no_conversion_spenders` are the candidates to check (landing page, price, stock) before excluding them; one or two weeks without a conversion can be normal for expensive products
|
|
609
613
|
- Pause an underperforming asset group with `pause_entity(entity_type="asset_group")` rather than removing it
|
|
610
614
|
- Check that final URLs point to relevant, working landing pages
|
|
611
615
|
6. New PMax campaigns cannot be built through AdLoop yet (the campaign, asset group and full asset set have to be created in one request); building them stays a job for Google Ads. Editing existing ones works as above.
|
|
@@ -1221,6 +1221,39 @@ def get_merchant_feed_health(account_id: str = "") -> dict:
|
|
|
1221
1221
|
return _impl(current_config(), account_id=account_id)
|
|
1222
1222
|
|
|
1223
1223
|
|
|
1224
|
+
@_tool(title="Merchant Center product issues", annotations=_READONLY, tags={"merchant"})
|
|
1225
|
+
@_safe
|
|
1226
|
+
def get_merchant_product_issues(
|
|
1227
|
+
account_id: str = "",
|
|
1228
|
+
status: str = "disapproved",
|
|
1229
|
+
issue_code: str = "",
|
|
1230
|
+
limit: int = 50,
|
|
1231
|
+
) -> dict:
|
|
1232
|
+
"""The products behind the feed-health counts, each with its issues.
|
|
1233
|
+
|
|
1234
|
+
Lists products by status with offer id, title, brand, click potential
|
|
1235
|
+
and every item issue: its code, the attribute it concerns, severity per
|
|
1236
|
+
reporting context with the affected countries, and whether the
|
|
1237
|
+
merchant has to act or Google is still processing. Products with high
|
|
1238
|
+
click potential come first, because fixing them recovers the most
|
|
1239
|
+
traffic. issues_by_code counts the products per issue. Data lags feed
|
|
1240
|
+
changes by about 30 minutes.
|
|
1241
|
+
|
|
1242
|
+
Args:
|
|
1243
|
+
account_id: Numeric Merchant Center ID from list_merchant_accounts.
|
|
1244
|
+
Defaults to merchant.account_id in the config.
|
|
1245
|
+
status: "disapproved" (default, not serving), "limited" (serving
|
|
1246
|
+
with restrictions), "pending" (still in review) or "any"
|
|
1247
|
+
(every product with at least one issue).
|
|
1248
|
+
issue_code: Only products with this issue code (from
|
|
1249
|
+
issues_by_code or get_merchant_feed_health).
|
|
1250
|
+
limit: Maximum number of products to return (1-200, default 50).
|
|
1251
|
+
"""
|
|
1252
|
+
from adloop.merchant.read import get_merchant_product_issues as _impl
|
|
1253
|
+
|
|
1254
|
+
return _impl(current_config(), account_id=account_id, status=status, issue_code=issue_code, limit=limit)
|
|
1255
|
+
|
|
1256
|
+
|
|
1224
1257
|
@_tool(title="List Google Ads accounts", annotations=_READONLY, tags={"ads"})
|
|
1225
1258
|
@_safe
|
|
1226
1259
|
def list_accounts(limit: int = 200) -> dict:
|
|
@@ -2805,6 +2838,42 @@ def draft_audience_attachment(
|
|
|
2805
2838
|
)
|
|
2806
2839
|
|
|
2807
2840
|
|
|
2841
|
+
@_tool(title="Shopping product performance", annotations=_READONLY, tags={"ads"})
|
|
2842
|
+
@_safe
|
|
2843
|
+
def get_shopping_product_performance(
|
|
2844
|
+
date_range_start: str = "",
|
|
2845
|
+
date_range_end: str = "",
|
|
2846
|
+
campaign_id: str = "",
|
|
2847
|
+
limit: int = 50,
|
|
2848
|
+
customer_id: str = "",
|
|
2849
|
+
) -> dict:
|
|
2850
|
+
"""Spend, clicks, conversions, CPA and ROAS per product across Shopping and Performance Max.
|
|
2851
|
+
|
|
2852
|
+
Each product (item id) is summed over the campaigns it ran in. Returns
|
|
2853
|
+
the account totals, the top products by spend, and the products that
|
|
2854
|
+
spent without a conversion, with insights on what to do with them.
|
|
2855
|
+
|
|
2856
|
+
Args:
|
|
2857
|
+
date_range_start: Start date "YYYY-MM-DD". Both dates empty means
|
|
2858
|
+
the last 30 days.
|
|
2859
|
+
date_range_end: End date "YYYY-MM-DD" (inclusive).
|
|
2860
|
+
campaign_id: Optional numeric campaign id to limit the report to.
|
|
2861
|
+
limit: Maximum number of products to return (1-200, default 50).
|
|
2862
|
+
customer_id: Google Ads customer ID (digits, dashes optional).
|
|
2863
|
+
Defaults to the configured account.
|
|
2864
|
+
"""
|
|
2865
|
+
from adloop.ads.shopping import get_shopping_product_performance as _impl
|
|
2866
|
+
|
|
2867
|
+
return _impl(
|
|
2868
|
+
current_config(),
|
|
2869
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
2870
|
+
date_range_start=date_range_start,
|
|
2871
|
+
date_range_end=date_range_end,
|
|
2872
|
+
campaign_id=campaign_id,
|
|
2873
|
+
limit=limit,
|
|
2874
|
+
)
|
|
2875
|
+
|
|
2876
|
+
|
|
2808
2877
|
@_tool(title="Asset group signals", annotations=_READONLY, tags={"ads"})
|
|
2809
2878
|
@_safe
|
|
2810
2879
|
def get_asset_group_signals(asset_group_id: str, customer_id: str = "") -> dict:
|
|
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
|