adloop 0.28.0__tar.gz → 0.29.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.28.0 → adloop-0.29.0}/PKG-INFO +5 -1
- {adloop-0.28.0 → adloop-0.29.0}/README.md +4 -0
- {adloop-0.28.0 → adloop-0.29.0}/pyproject.toml +1 -1
- {adloop-0.28.0 → adloop-0.29.0}/pyproject.toml.orig +1 -1
- adloop-0.29.0/src/adloop/ads/audiences.py +547 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/write.py +2 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/adloop.md +18 -2
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/server.py +125 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/__main__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/auth.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/cli.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/config.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/crossref.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/runtime.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/tracking.py +0 -0
- {adloop-0.28.0 → adloop-0.29.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.28.0 → adloop-0.29.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.29.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
|
|
@@ -138,6 +138,9 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
138
138
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
139
139
|
| `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
140
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
141
|
+
| `get_audience_lists` | The account's remarketing and customer lists (size, eligibility, open/closed), custom and combined segments |
|
|
142
|
+
| `search_audience_segments` | Find Google's in-market and affinity segments by name |
|
|
143
|
+
| `get_audience_targeting` | Audiences and audience exclusions on a campaign or ad group, plus the audience mode (observation or targeting) |
|
|
141
144
|
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
142
145
|
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
143
146
|
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
|
|
@@ -308,6 +311,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
308
311
|
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
309
312
|
| `draft_delete_key_event` | Remove a GA4 key event. The event keeps firing but no longer counts as a conversion, for future data only |
|
|
310
313
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
314
|
+
| `draft_audience_attachment` | Attach audiences as observation (default), targeting (double confirmation) or exclusion; refuses pairings Google doesn't allow and Search lists too small to target |
|
|
311
315
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
312
316
|
| `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
|
|
313
317
|
| `add_to_brand_list` | Draft adding brands to an existing brand list |
|
|
@@ -110,6 +110,9 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
110
110
|
| `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
|
|
111
111
|
| `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
112
|
| `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
|
|
113
|
+
| `get_audience_lists` | The account's remarketing and customer lists (size, eligibility, open/closed), custom and combined segments |
|
|
114
|
+
| `search_audience_segments` | Find Google's in-market and affinity segments by name |
|
|
115
|
+
| `get_audience_targeting` | Audiences and audience exclusions on a campaign or ad group, plus the audience mode (observation or targeting) |
|
|
113
116
|
| `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
|
|
114
117
|
| `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
|
|
115
118
|
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) — ID, name, status, member count |
|
|
@@ -280,6 +283,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
280
283
|
| `draft_key_event` | Mark a GA4 event as a key event (conversion) — the fix for "fires but isn't tracked as a conversion" |
|
|
281
284
|
| `draft_delete_key_event` | Remove a GA4 key event. The event keeps firing but no longer counts as a conversion, for future data only |
|
|
282
285
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
|
|
286
|
+
| `draft_audience_attachment` | Attach audiences as observation (default), targeting (double confirmation) or exclusion; refuses pairings Google doesn't allow and Search lists too small to target |
|
|
283
287
|
| `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
|
|
284
288
|
| `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) from Commercial KG MIDs and optionally attach it to campaigns — `negative=true` (default) excludes the brands, `false` restricts targeting to them |
|
|
285
289
|
| `add_to_brand_list` | Draft adding brands to an existing brand list |
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Google Ads audiences: list them, see what is attached, attach them (#48).
|
|
2
|
+
|
|
3
|
+
Reads cover the account's own audiences (remarketing/customer lists, custom
|
|
4
|
+
segments, combined segments), Google's segment catalog (in-market and
|
|
5
|
+
affinity), and the audiences attached to one campaign or ad group together
|
|
6
|
+
with its audience mode.
|
|
7
|
+
|
|
8
|
+
``draft_audience_attachment`` attaches audiences in one atomic
|
|
9
|
+
GoogleAdsService.Mutate. Observation is the default: the audiences only
|
|
10
|
+
adjust bids and report, reach stays the same. Targeting narrows serving to
|
|
11
|
+
the audiences, so it has to be asked for explicitly and needs a double
|
|
12
|
+
confirmation. The mode is the entity's AUDIENCE target restriction
|
|
13
|
+
(``bid_only``); it is always written explicitly with the attachment, so the
|
|
14
|
+
result never depends on Google's default for the campaign type. Exclusions
|
|
15
|
+
(``exclude=True``) are negative criteria and leave the mode alone.
|
|
16
|
+
|
|
17
|
+
Performance Max takes audiences only as asset group signals
|
|
18
|
+
(draft_asset_group_signals) and is refused here with that pointer. Creating
|
|
19
|
+
audiences is out of scope.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from adloop.config import AdLoopConfig
|
|
28
|
+
|
|
29
|
+
# Audience kinds a criterion can point at: (criterion type in GAQL,
|
|
30
|
+
# criterion field, resource collection).
|
|
31
|
+
_KINDS = {
|
|
32
|
+
"user_list": ("USER_LIST", "user_list.user_list", "userLists"),
|
|
33
|
+
"segment": ("USER_INTEREST", "user_interest.user_interest_category", "userInterests"),
|
|
34
|
+
"custom_audience": ("CUSTOM_AUDIENCE", "custom_audience.custom_audience", "customAudiences"),
|
|
35
|
+
"combined_audience": ("COMBINED_AUDIENCE", "combined_audience.combined_audience", "combinedAudiences"),
|
|
36
|
+
}
|
|
37
|
+
_TYPE_TO_KIND = {v[0]: k for k, v in _KINDS.items()}
|
|
38
|
+
|
|
39
|
+
# Which kinds each campaign type accepts. Custom segments cannot be attached
|
|
40
|
+
# to Search; combined segments are Search-only. Google's dry run checks the
|
|
41
|
+
# rest (e.g. level rules) against the live account.
|
|
42
|
+
_COMPATIBLE = {
|
|
43
|
+
"SEARCH": {"user_list", "segment", "combined_audience"},
|
|
44
|
+
"DISPLAY": {"user_list", "segment", "custom_audience"},
|
|
45
|
+
"VIDEO": {"user_list", "segment", "custom_audience"},
|
|
46
|
+
"SHOPPING": {"user_list", "segment"},
|
|
47
|
+
}
|
|
48
|
+
_KIND_LABELS = {
|
|
49
|
+
"user_list": "remarketing or customer lists",
|
|
50
|
+
"segment": "in-market and affinity segments",
|
|
51
|
+
"custom_audience": "custom segments",
|
|
52
|
+
"combined_audience": "combined segments",
|
|
53
|
+
}
|
|
54
|
+
# Search shows ads to a remarketing list once it has this many active users.
|
|
55
|
+
_SEARCH_MIN_SIZE = 1000
|
|
56
|
+
_MAX_PER_DRAFT = 50
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _id(value: object) -> str:
|
|
60
|
+
return str(value or "").strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _last(resource: str | None) -> str:
|
|
64
|
+
return str(resource or "").rsplit("/", 1)[-1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_audience_lists(config: AdLoopConfig, *, customer_id: str = "") -> dict:
|
|
68
|
+
"""The account's own audiences: remarketing/customer lists, custom and combined segments."""
|
|
69
|
+
from adloop.ads.gaql import execute_query
|
|
70
|
+
|
|
71
|
+
lists = execute_query(
|
|
72
|
+
config,
|
|
73
|
+
customer_id,
|
|
74
|
+
"SELECT user_list.id, user_list.name, user_list.type, user_list.membership_status, "
|
|
75
|
+
"user_list.size_for_search, user_list.size_for_display, user_list.eligible_for_search, "
|
|
76
|
+
"user_list.eligible_for_display, user_list.membership_life_span, "
|
|
77
|
+
"user_list.account_user_list_status FROM user_list",
|
|
78
|
+
)
|
|
79
|
+
custom = execute_query(
|
|
80
|
+
config,
|
|
81
|
+
customer_id,
|
|
82
|
+
"SELECT custom_audience.id, custom_audience.name, custom_audience.type, custom_audience.description "
|
|
83
|
+
"FROM custom_audience WHERE custom_audience.status = 'ENABLED'",
|
|
84
|
+
)
|
|
85
|
+
combined = execute_query(
|
|
86
|
+
config,
|
|
87
|
+
customer_id,
|
|
88
|
+
"SELECT combined_audience.id, combined_audience.name, combined_audience.description "
|
|
89
|
+
"FROM combined_audience WHERE combined_audience.status = 'ENABLED'",
|
|
90
|
+
)
|
|
91
|
+
user_lists = []
|
|
92
|
+
for row in lists:
|
|
93
|
+
if row.get("user_list.account_user_list_status") == "CLOSED":
|
|
94
|
+
continue
|
|
95
|
+
user_lists.append({
|
|
96
|
+
"user_list_id": str(row.get("user_list.id")),
|
|
97
|
+
"name": row.get("user_list.name"),
|
|
98
|
+
"type": row.get("user_list.type"),
|
|
99
|
+
"membership_status": row.get("user_list.membership_status"),
|
|
100
|
+
"size_for_search": row.get("user_list.size_for_search"),
|
|
101
|
+
"size_for_display": row.get("user_list.size_for_display"),
|
|
102
|
+
"eligible_for_search": row.get("user_list.eligible_for_search"),
|
|
103
|
+
"eligible_for_display": row.get("user_list.eligible_for_display"),
|
|
104
|
+
"membership_life_span_days": row.get("user_list.membership_life_span"),
|
|
105
|
+
})
|
|
106
|
+
insights = []
|
|
107
|
+
small = [u["name"] for u in user_lists if (u["size_for_search"] or 0) < _SEARCH_MIN_SIZE and u["eligible_for_search"]]
|
|
108
|
+
if small:
|
|
109
|
+
insights.append(
|
|
110
|
+
f"Below {_SEARCH_MIN_SIZE:,} users for Search (attachable, but they adjust nothing there until they grow): "
|
|
111
|
+
+ ", ".join(small[:10])
|
|
112
|
+
)
|
|
113
|
+
closed = [u["name"] for u in user_lists if u["membership_status"] == "CLOSED"]
|
|
114
|
+
if closed:
|
|
115
|
+
insights.append("Closed lists no longer add members and shrink over time: " + ", ".join(closed[:10]))
|
|
116
|
+
if not user_lists and not custom and not combined:
|
|
117
|
+
insights.append(
|
|
118
|
+
"No audiences of your own yet. In-market and affinity segments (search_audience_segments) work without any."
|
|
119
|
+
)
|
|
120
|
+
return {
|
|
121
|
+
"user_lists": user_lists,
|
|
122
|
+
"custom_segments": [
|
|
123
|
+
{"custom_audience_id": str(r.get("custom_audience.id")), "name": r.get("custom_audience.name"),
|
|
124
|
+
"type": r.get("custom_audience.type"), "description": r.get("custom_audience.description")}
|
|
125
|
+
for r in custom
|
|
126
|
+
],
|
|
127
|
+
"combined_segments": [
|
|
128
|
+
{"combined_audience_id": str(r.get("combined_audience.id")), "name": r.get("combined_audience.name"),
|
|
129
|
+
"description": r.get("combined_audience.description")}
|
|
130
|
+
for r in combined
|
|
131
|
+
],
|
|
132
|
+
"insights": insights,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def search_audience_segments(
|
|
137
|
+
config: AdLoopConfig,
|
|
138
|
+
*,
|
|
139
|
+
customer_id: str = "",
|
|
140
|
+
query: str = "",
|
|
141
|
+
kind: str = "all",
|
|
142
|
+
limit: int = 25,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Google's in-market and affinity segments whose name matches ``query``."""
|
|
145
|
+
from adloop.ads.gaql import execute_query
|
|
146
|
+
|
|
147
|
+
query = str(query or "").strip()
|
|
148
|
+
if len(query) < 2:
|
|
149
|
+
return {"error": "query needs at least 2 characters, e.g. 'running' or 'insurance'"}
|
|
150
|
+
taxonomies = {"in_market": ["IN_MARKET"], "affinity": ["AFFINITY"], "all": ["IN_MARKET", "AFFINITY"]}
|
|
151
|
+
if kind not in taxonomies:
|
|
152
|
+
return {"error": "kind must be in_market, affinity or all"}
|
|
153
|
+
limit = max(1, min(int(limit or 25), 100))
|
|
154
|
+
# GAQL's LIKE is case-sensitive and the catalog is small (about 1,500
|
|
155
|
+
# segments), so match here instead. launched_to_all is False for most
|
|
156
|
+
# segments ("not in every channel and locale"), so it is no filter.
|
|
157
|
+
rows = execute_query(
|
|
158
|
+
config,
|
|
159
|
+
customer_id,
|
|
160
|
+
"SELECT user_interest.user_interest_id, user_interest.name, user_interest.taxonomy_type FROM user_interest "
|
|
161
|
+
f"WHERE user_interest.taxonomy_type IN ({', '.join(repr(t) for t in taxonomies[kind])})",
|
|
162
|
+
)
|
|
163
|
+
needle = query.lower()
|
|
164
|
+
matches = [r for r in rows if needle in str(r.get("user_interest.name") or "").lower()]
|
|
165
|
+
# Names that start with the query first, then the rest alphabetically.
|
|
166
|
+
matches.sort(key=lambda r: (not str(r.get("user_interest.name")).lower().startswith(needle), str(r.get("user_interest.name"))))
|
|
167
|
+
segments = [
|
|
168
|
+
{
|
|
169
|
+
"segment_id": str(r.get("user_interest.user_interest_id")),
|
|
170
|
+
"name": r.get("user_interest.name"),
|
|
171
|
+
"kind": "in_market" if r.get("user_interest.taxonomy_type") == "IN_MARKET" else "affinity",
|
|
172
|
+
}
|
|
173
|
+
for r in matches[:limit]
|
|
174
|
+
]
|
|
175
|
+
return {"query": query, "segments": segments, "count": len(segments)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _entity_context(config: AdLoopConfig, customer_id: str, campaign_id: str, ad_group_id: str) -> dict:
|
|
179
|
+
"""Channel, names, the AUDIENCE mode and the audience criteria at both levels."""
|
|
180
|
+
from adloop.ads.gaql import execute_query
|
|
181
|
+
|
|
182
|
+
if ad_group_id:
|
|
183
|
+
rows = execute_query(
|
|
184
|
+
config,
|
|
185
|
+
customer_id,
|
|
186
|
+
"SELECT ad_group.id, ad_group.name, ad_group.targeting_setting.target_restrictions, campaign.id, "
|
|
187
|
+
"campaign.name, campaign.advertising_channel_type FROM ad_group "
|
|
188
|
+
f"WHERE ad_group.id = {int(ad_group_id)}",
|
|
189
|
+
)
|
|
190
|
+
if not rows:
|
|
191
|
+
raise ValueError(f"ad group {ad_group_id} was not found")
|
|
192
|
+
row = rows[0]
|
|
193
|
+
restrictions = row.get("ad_group.targeting_setting.target_restrictions") or []
|
|
194
|
+
campaign_id = str(row.get("campaign.id"))
|
|
195
|
+
entity_name = row.get("ad_group.name")
|
|
196
|
+
else:
|
|
197
|
+
rows = execute_query(
|
|
198
|
+
config,
|
|
199
|
+
customer_id,
|
|
200
|
+
"SELECT campaign.id, campaign.name, campaign.advertising_channel_type, "
|
|
201
|
+
"campaign.targeting_setting.target_restrictions FROM campaign "
|
|
202
|
+
f"WHERE campaign.id = {int(campaign_id)}",
|
|
203
|
+
)
|
|
204
|
+
if not rows:
|
|
205
|
+
raise ValueError(f"campaign {campaign_id} was not found")
|
|
206
|
+
row = rows[0]
|
|
207
|
+
restrictions = row.get("campaign.targeting_setting.target_restrictions") or []
|
|
208
|
+
entity_name = row.get("campaign.name")
|
|
209
|
+
|
|
210
|
+
types = ", ".join(f"'{t}'" for t, _f, _c in _KINDS.values())
|
|
211
|
+
campaign_criteria = execute_query(
|
|
212
|
+
config,
|
|
213
|
+
customer_id,
|
|
214
|
+
"SELECT campaign_criterion.criterion_id, campaign_criterion.type, campaign_criterion.negative, "
|
|
215
|
+
"campaign_criterion.user_list.user_list, campaign_criterion.user_interest.user_interest_category, "
|
|
216
|
+
"campaign_criterion.custom_audience.custom_audience, campaign_criterion.combined_audience.combined_audience "
|
|
217
|
+
f"FROM campaign_criterion WHERE campaign.id = {int(campaign_id)} AND campaign_criterion.type IN ({types}) "
|
|
218
|
+
"AND campaign_criterion.status != 'REMOVED'",
|
|
219
|
+
)
|
|
220
|
+
ad_group_criteria = execute_query(
|
|
221
|
+
config,
|
|
222
|
+
customer_id,
|
|
223
|
+
"SELECT ad_group.id, ad_group_criterion.criterion_id, ad_group_criterion.type, ad_group_criterion.negative, "
|
|
224
|
+
"ad_group_criterion.user_list.user_list, ad_group_criterion.user_interest.user_interest_category, "
|
|
225
|
+
"ad_group_criterion.custom_audience.custom_audience, ad_group_criterion.combined_audience.combined_audience "
|
|
226
|
+
f"FROM ad_group_criterion WHERE campaign.id = {int(campaign_id)} AND ad_group_criterion.type IN ({types}) "
|
|
227
|
+
"AND ad_group_criterion.status != 'REMOVED'",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def criteria(rows: list[dict], prefix: str, owner: str) -> list[dict]:
|
|
231
|
+
out = []
|
|
232
|
+
for r in rows:
|
|
233
|
+
kind = _TYPE_TO_KIND.get(str(r.get(f"{prefix}.type") or ""))
|
|
234
|
+
if not kind:
|
|
235
|
+
continue
|
|
236
|
+
out.append({
|
|
237
|
+
"kind": kind,
|
|
238
|
+
"audience_id": _last(r.get(f"{prefix}.{_KINDS[kind][1]}")),
|
|
239
|
+
"negative": bool(r.get(f"{prefix}.negative")),
|
|
240
|
+
"remove_id": f"{owner if owner else r.get('ad_group.id')}~{r.get(f'{prefix}.criterion_id')}",
|
|
241
|
+
"ad_group_id": str(r.get("ad_group.id")) if not owner else None,
|
|
242
|
+
})
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
mode = None
|
|
246
|
+
for restriction in restrictions:
|
|
247
|
+
if isinstance(restriction, dict) and restriction.get("targeting_dimension") == "AUDIENCE":
|
|
248
|
+
mode = "observation" if restriction.get("bid_only") else "targeting"
|
|
249
|
+
return {
|
|
250
|
+
"campaign_id": campaign_id,
|
|
251
|
+
"channel": str(row.get("campaign.advertising_channel_type") or ""),
|
|
252
|
+
"campaign_name": row.get("campaign.name"),
|
|
253
|
+
"entity_name": entity_name,
|
|
254
|
+
"mode": mode,
|
|
255
|
+
"restrictions": [r for r in restrictions if isinstance(r, dict)],
|
|
256
|
+
"campaign_criteria": criteria(campaign_criteria, "campaign_criterion", campaign_id),
|
|
257
|
+
"ad_group_criteria": criteria(ad_group_criteria, "ad_group_criterion", ""),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _names(config: AdLoopConfig, customer_id: str, entries: list[dict], lists: dict | None = None) -> None:
|
|
262
|
+
"""Fill in ``name`` for criteria entries (lists from get_audience_lists, segments by id)."""
|
|
263
|
+
from adloop.ads.gaql import execute_query
|
|
264
|
+
|
|
265
|
+
lists = lists or get_audience_lists(config, customer_id=customer_id)
|
|
266
|
+
names = {("user_list", u["user_list_id"]): u["name"] for u in lists["user_lists"]}
|
|
267
|
+
names |= {("custom_audience", c["custom_audience_id"]): c["name"] for c in lists["custom_segments"]}
|
|
268
|
+
names |= {("combined_audience", c["combined_audience_id"]): c["name"] for c in lists["combined_segments"]}
|
|
269
|
+
segment_ids = sorted({e["audience_id"] for e in entries if e["kind"] == "segment" and e["audience_id"].isdigit()})
|
|
270
|
+
if segment_ids:
|
|
271
|
+
rows = execute_query(
|
|
272
|
+
config,
|
|
273
|
+
customer_id,
|
|
274
|
+
"SELECT user_interest.user_interest_id, user_interest.name, user_interest.taxonomy_type FROM user_interest "
|
|
275
|
+
f"WHERE user_interest.user_interest_id IN ({', '.join(segment_ids)})",
|
|
276
|
+
)
|
|
277
|
+
names |= {("segment", str(r.get("user_interest.user_interest_id"))): r.get("user_interest.name") for r in rows}
|
|
278
|
+
for entry in entries:
|
|
279
|
+
entry["name"] = names.get((entry["kind"], entry["audience_id"]))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_audience_targeting(
|
|
283
|
+
config: AdLoopConfig,
|
|
284
|
+
*,
|
|
285
|
+
customer_id: str = "",
|
|
286
|
+
campaign_id: str = "",
|
|
287
|
+
ad_group_id: str = "",
|
|
288
|
+
) -> dict:
|
|
289
|
+
"""Audiences attached to one campaign or ad group, with the audience mode."""
|
|
290
|
+
campaign_id, ad_group_id = _id(campaign_id), _id(ad_group_id)
|
|
291
|
+
if bool(campaign_id) == bool(ad_group_id):
|
|
292
|
+
return {"error": "Provide exactly one of campaign_id or ad_group_id"}
|
|
293
|
+
if not (campaign_id or ad_group_id).isdigit():
|
|
294
|
+
return {"error": "campaign_id / ad_group_id must be a numeric Google Ads id"}
|
|
295
|
+
try:
|
|
296
|
+
ctx = _entity_context(config, customer_id, campaign_id, ad_group_id)
|
|
297
|
+
except ValueError as e:
|
|
298
|
+
return {"error": str(e)}
|
|
299
|
+
if ad_group_id:
|
|
300
|
+
attached = [c for c in ctx["ad_group_criteria"] if c["ad_group_id"] == ad_group_id]
|
|
301
|
+
else:
|
|
302
|
+
attached = list(ctx["campaign_criteria"])
|
|
303
|
+
_names(config, customer_id, attached)
|
|
304
|
+
for entry in attached:
|
|
305
|
+
entry.pop("ad_group_id", None)
|
|
306
|
+
insights = []
|
|
307
|
+
if ctx["mode"] == "targeting" and any(not c["negative"] for c in attached):
|
|
308
|
+
insights.append("Targeting mode: ads only reach people in the attached audiences.")
|
|
309
|
+
if ad_group_id and any(not c["negative"] for c in ctx["campaign_criteria"]):
|
|
310
|
+
insights.append("The campaign has audiences of its own; Google does not allow audiences at both levels.")
|
|
311
|
+
return {
|
|
312
|
+
"level": "ad_group" if ad_group_id else "campaign",
|
|
313
|
+
"campaign_id": ctx["campaign_id"],
|
|
314
|
+
"campaign_name": ctx["campaign_name"],
|
|
315
|
+
"channel": ctx["channel"],
|
|
316
|
+
"audience_mode": ctx["mode"] or "not set",
|
|
317
|
+
"audiences": [c for c in attached if not c["negative"]],
|
|
318
|
+
"exclusions": [c for c in attached if c["negative"]],
|
|
319
|
+
"insights": insights,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def draft_audience_attachment(
|
|
324
|
+
config: AdLoopConfig,
|
|
325
|
+
*,
|
|
326
|
+
customer_id: str = "",
|
|
327
|
+
campaign_id: str = "",
|
|
328
|
+
ad_group_id: str = "",
|
|
329
|
+
user_list_ids: list[str] | None = None,
|
|
330
|
+
segment_ids: list[str] | None = None,
|
|
331
|
+
custom_audience_ids: list[str] | None = None,
|
|
332
|
+
combined_audience_ids: list[str] | None = None,
|
|
333
|
+
mode: str = "observation",
|
|
334
|
+
exclude: bool = False,
|
|
335
|
+
) -> dict:
|
|
336
|
+
"""Draft attaching audiences to a campaign or ad group: returns a preview."""
|
|
337
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
338
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
check_blocked_operation("add_audience_criteria", config.safety)
|
|
342
|
+
except SafetyViolation as e:
|
|
343
|
+
return {"error": str(e)}
|
|
344
|
+
|
|
345
|
+
campaign_id, ad_group_id = _id(campaign_id), _id(ad_group_id)
|
|
346
|
+
requested = {
|
|
347
|
+
"user_list": user_list_ids,
|
|
348
|
+
"segment": segment_ids,
|
|
349
|
+
"custom_audience": custom_audience_ids,
|
|
350
|
+
"combined_audience": combined_audience_ids,
|
|
351
|
+
}
|
|
352
|
+
wanted = [
|
|
353
|
+
{"kind": kind, "audience_id": _id(value)}
|
|
354
|
+
for kind, values in requested.items()
|
|
355
|
+
for value in dict.fromkeys(values or [])
|
|
356
|
+
if _id(value)
|
|
357
|
+
]
|
|
358
|
+
errors = []
|
|
359
|
+
if bool(campaign_id) == bool(ad_group_id):
|
|
360
|
+
errors.append("Provide exactly one of campaign_id or ad_group_id")
|
|
361
|
+
elif not (campaign_id or ad_group_id).isdigit():
|
|
362
|
+
errors.append("campaign_id / ad_group_id must be a numeric Google Ads id")
|
|
363
|
+
if not wanted:
|
|
364
|
+
errors.append("Pass at least one of user_list_ids, segment_ids, custom_audience_ids, combined_audience_ids")
|
|
365
|
+
bad = [w["audience_id"] for w in wanted if not w["audience_id"].isdigit()]
|
|
366
|
+
if bad:
|
|
367
|
+
errors.append(f"audience ids must be numeric: {', '.join(bad)}")
|
|
368
|
+
if len(wanted) > _MAX_PER_DRAFT:
|
|
369
|
+
errors.append(f"at most {_MAX_PER_DRAFT} audiences per draft")
|
|
370
|
+
if mode not in ("observation", "targeting"):
|
|
371
|
+
errors.append("mode must be 'observation' (default, bids and reporting only) or 'targeting'")
|
|
372
|
+
if errors:
|
|
373
|
+
return {"error": "Validation failed", "details": errors}
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
ctx = _entity_context(config, customer_id, campaign_id, ad_group_id)
|
|
377
|
+
except ValueError as e:
|
|
378
|
+
return {"error": str(e)}
|
|
379
|
+
channel = ctx["channel"]
|
|
380
|
+
if channel == "PERFORMANCE_MAX":
|
|
381
|
+
return {"error": (
|
|
382
|
+
"Performance Max takes audiences only as asset group signals, not as targeting. "
|
|
383
|
+
"Use get_asset_group_signals and draft_asset_group_signals instead."
|
|
384
|
+
)}
|
|
385
|
+
allowed = _COMPATIBLE.get(channel)
|
|
386
|
+
if allowed is None:
|
|
387
|
+
return {"error": f"Attaching audiences to {channel or 'this'} campaigns is not supported yet; use the Google Ads interface."}
|
|
388
|
+
for kind in {w["kind"] for w in wanted} - allowed:
|
|
389
|
+
errors.append(
|
|
390
|
+
f"{_KIND_LABELS[kind].capitalize()} cannot be attached to {channel} campaigns"
|
|
391
|
+
+ (" (custom segments work on Display and Video)" if kind == "custom_audience" else "")
|
|
392
|
+
+ (" (combined segments are Search-only)" if kind == "combined_audience" else "")
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
lists = get_audience_lists(config, customer_id=customer_id)
|
|
396
|
+
known = {
|
|
397
|
+
"user_list": {u["user_list_id"]: u for u in lists["user_lists"]},
|
|
398
|
+
"custom_audience": {c["custom_audience_id"]: c for c in lists["custom_segments"]},
|
|
399
|
+
"combined_audience": {c["combined_audience_id"]: c for c in lists["combined_segments"]},
|
|
400
|
+
}
|
|
401
|
+
_names(config, customer_id, wanted, lists)
|
|
402
|
+
warnings = []
|
|
403
|
+
for w in wanted:
|
|
404
|
+
if w["kind"] == "segment":
|
|
405
|
+
if not w["name"]:
|
|
406
|
+
errors.append(f"segment {w['audience_id']} was not found (see search_audience_segments)")
|
|
407
|
+
continue
|
|
408
|
+
info = known[w["kind"]].get(w["audience_id"])
|
|
409
|
+
if not info:
|
|
410
|
+
errors.append(f"{w['kind'].replace('_', ' ')} {w['audience_id']} is not an audience of this account (see get_audience_lists)")
|
|
411
|
+
continue
|
|
412
|
+
if w["kind"] == "user_list":
|
|
413
|
+
if channel == "SEARCH" and info.get("eligible_for_search") is False:
|
|
414
|
+
errors.append(f"list '{info['name']}' is not eligible for Search")
|
|
415
|
+
elif channel == "SEARCH" and (info.get("size_for_search") or 0) < _SEARCH_MIN_SIZE:
|
|
416
|
+
size = info.get("size_for_search") or 0
|
|
417
|
+
if mode == "targeting" and not exclude:
|
|
418
|
+
errors.append(
|
|
419
|
+
f"list '{info['name']}' has {size:,} Search users; with targeting the ads would not serve "
|
|
420
|
+
f"until it reaches {_SEARCH_MIN_SIZE:,}. Use observation, or wait for the list to grow."
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
warnings.append(f"list '{info['name']}' has {size:,} Search users; it takes effect from {_SEARCH_MIN_SIZE:,}")
|
|
424
|
+
if info.get("membership_status") == "CLOSED":
|
|
425
|
+
warnings.append(f"list '{info['name']}' is closed: it no longer adds members")
|
|
426
|
+
|
|
427
|
+
# Google keeps audiences at one level per campaign.
|
|
428
|
+
if not exclude:
|
|
429
|
+
if ad_group_id and any(not c["negative"] for c in ctx["campaign_criteria"]):
|
|
430
|
+
errors.append("The campaign already has audiences; Google does not allow audiences at both campaign and ad group level. Attach at the campaign instead.")
|
|
431
|
+
if campaign_id and any(not c["negative"] for c in ctx["ad_group_criteria"]):
|
|
432
|
+
errors.append("Ad groups of this campaign already have audiences; Google does not allow both levels. Attach at those ad groups instead.")
|
|
433
|
+
if errors:
|
|
434
|
+
return {"error": "Validation failed", "details": errors}
|
|
435
|
+
|
|
436
|
+
existing = ctx["ad_group_criteria"] if ad_group_id else ctx["campaign_criteria"]
|
|
437
|
+
present = {
|
|
438
|
+
(c["kind"], c["audience_id"], c["negative"])
|
|
439
|
+
for c in existing
|
|
440
|
+
if not ad_group_id or c["ad_group_id"] == ad_group_id
|
|
441
|
+
}
|
|
442
|
+
duplicates = [w for w in wanted if (w["kind"], w["audience_id"], exclude) in present]
|
|
443
|
+
for w in duplicates:
|
|
444
|
+
warnings.append(f"'{w['name'] or w['audience_id']}' is already attached; skipped")
|
|
445
|
+
wanted = [w for w in wanted if w not in duplicates]
|
|
446
|
+
if not wanted:
|
|
447
|
+
return {"error": "Nothing to attach: every audience is already there", "warnings": warnings}
|
|
448
|
+
|
|
449
|
+
set_mode = None
|
|
450
|
+
double_confirm = False
|
|
451
|
+
if not exclude and ctx["mode"] != mode:
|
|
452
|
+
set_mode = mode
|
|
453
|
+
others = [c for c in existing if not c["negative"] and (not ad_group_id or c["ad_group_id"] == ad_group_id)]
|
|
454
|
+
if mode == "targeting":
|
|
455
|
+
double_confirm = True
|
|
456
|
+
warnings.insert(0, (
|
|
457
|
+
"TARGETING narrows delivery: from now on the ads only reach people in the attached audiences"
|
|
458
|
+
+ (" (including the ones already attached)" if others else "")
|
|
459
|
+
+ ". Impressions and clicks usually drop sharply. Observation keeps the reach and only adjusts bids."
|
|
460
|
+
))
|
|
461
|
+
elif ctx["mode"] == "targeting" and others:
|
|
462
|
+
warnings.insert(0, "Switches from targeting to observation: the audiences already attached stop restricting delivery, so reach grows.")
|
|
463
|
+
elif mode == "targeting" and not exclude:
|
|
464
|
+
warnings.insert(0, "The audience mode is already targeting: the ads only reach people in the attached audiences.")
|
|
465
|
+
if exclude:
|
|
466
|
+
warnings.append("Exclusions keep these people from seeing the ads; the audience mode is unchanged.")
|
|
467
|
+
|
|
468
|
+
plan = ChangePlan(
|
|
469
|
+
operation="add_audience_criteria",
|
|
470
|
+
entity_type="ad_group" if ad_group_id else "campaign",
|
|
471
|
+
entity_id=ad_group_id or campaign_id,
|
|
472
|
+
customer_id=customer_id,
|
|
473
|
+
requires_double_confirm=double_confirm,
|
|
474
|
+
changes={
|
|
475
|
+
"level": "ad_group" if ad_group_id else "campaign",
|
|
476
|
+
"campaign_id": ctx["campaign_id"],
|
|
477
|
+
"ad_group_id": ad_group_id or None,
|
|
478
|
+
"name": ctx["entity_name"],
|
|
479
|
+
"channel": channel,
|
|
480
|
+
"exclude": bool(exclude),
|
|
481
|
+
"audiences": [{"kind": w["kind"], "audience_id": w["audience_id"], "name": w["name"]} for w in wanted],
|
|
482
|
+
"audience_mode_before": ctx["mode"] or "not set",
|
|
483
|
+
"set_audience_mode": set_mode,
|
|
484
|
+
"target_restrictions": ctx["restrictions"],
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
store_plan(plan)
|
|
488
|
+
preview = plan.to_preview()
|
|
489
|
+
preview["warnings"] = warnings
|
|
490
|
+
return preview
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def apply_audience_criteria(client: object, cid: str, changes: dict) -> dict:
|
|
494
|
+
"""One mutate: the audience mode (if it changes), then the criteria."""
|
|
495
|
+
service = client.get_service("GoogleAdsService")
|
|
496
|
+
at_ad_group = changes["level"] == "ad_group"
|
|
497
|
+
operations = []
|
|
498
|
+
|
|
499
|
+
if changes.get("set_audience_mode"):
|
|
500
|
+
op = client.get_type("MutateOperation")
|
|
501
|
+
if at_ad_group:
|
|
502
|
+
entity = op.ad_group_operation.update
|
|
503
|
+
entity.resource_name = service.ad_group_path(cid, changes["ad_group_id"])
|
|
504
|
+
mask = op.ad_group_operation.update_mask
|
|
505
|
+
else:
|
|
506
|
+
entity = op.campaign_operation.update
|
|
507
|
+
entity.resource_name = service.campaign_path(cid, changes["campaign_id"])
|
|
508
|
+
mask = op.campaign_operation.update_mask
|
|
509
|
+
dimension = client.enums.TargetingDimensionEnum
|
|
510
|
+
# The mask replaces the whole list: keep the other dimensions as they are.
|
|
511
|
+
for existing in changes.get("target_restrictions") or []:
|
|
512
|
+
if existing.get("targeting_dimension") == "AUDIENCE":
|
|
513
|
+
continue
|
|
514
|
+
kept = client.get_type("TargetRestriction")
|
|
515
|
+
kept.targeting_dimension = getattr(dimension, existing["targeting_dimension"])
|
|
516
|
+
kept.bid_only = bool(existing.get("bid_only"))
|
|
517
|
+
entity.targeting_setting.target_restrictions.append(kept)
|
|
518
|
+
audience = client.get_type("TargetRestriction")
|
|
519
|
+
audience.targeting_dimension = dimension.AUDIENCE
|
|
520
|
+
audience.bid_only = changes["set_audience_mode"] == "observation"
|
|
521
|
+
entity.targeting_setting.target_restrictions.append(audience)
|
|
522
|
+
mask.paths.append("targeting_setting.target_restrictions")
|
|
523
|
+
operations.append(op)
|
|
524
|
+
|
|
525
|
+
for entry in changes["audiences"]:
|
|
526
|
+
_type, field, collection = _KINDS[entry["kind"]]
|
|
527
|
+
op = client.get_type("MutateOperation")
|
|
528
|
+
if at_ad_group:
|
|
529
|
+
criterion = op.ad_group_criterion_operation.create
|
|
530
|
+
criterion.ad_group = service.ad_group_path(cid, changes["ad_group_id"])
|
|
531
|
+
else:
|
|
532
|
+
criterion = op.campaign_criterion_operation.create
|
|
533
|
+
criterion.campaign = service.campaign_path(cid, changes["campaign_id"])
|
|
534
|
+
message, attribute = field.split(".")
|
|
535
|
+
setattr(getattr(criterion, message), attribute, f"customers/{cid}/{collection}/{entry['audience_id']}")
|
|
536
|
+
if changes.get("exclude"):
|
|
537
|
+
criterion.negative = True
|
|
538
|
+
operations.append(op)
|
|
539
|
+
|
|
540
|
+
service.mutate(customer_id=cid, mutate_operations=operations)
|
|
541
|
+
return {
|
|
542
|
+
"level": changes["level"],
|
|
543
|
+
"entity_id": changes.get("ad_group_id") or changes["campaign_id"],
|
|
544
|
+
"audiences_attached": len(changes["audiences"]),
|
|
545
|
+
"excluded": bool(changes.get("exclude")),
|
|
546
|
+
"audience_mode": changes.get("set_audience_mode") or changes.get("audience_mode_before"),
|
|
547
|
+
}
|
|
@@ -3976,6 +3976,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
|
|
|
3976
3976
|
_apply_upload_call_conversions,
|
|
3977
3977
|
_apply_upload_enhanced_conversions_for_leads,
|
|
3978
3978
|
)
|
|
3979
|
+
from adloop.ads.audiences import apply_audience_criteria as _apply_audience_criteria
|
|
3979
3980
|
from adloop.ads.shopping import apply_create_shopping_campaign as _apply_create_shopping_campaign
|
|
3980
3981
|
from adloop.ads.pmax import apply_asset_group_assets as _apply_asset_group_assets
|
|
3981
3982
|
from adloop.ads.pmax import apply_asset_group_signals as _apply_asset_group_signals
|
|
@@ -4007,6 +4008,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
|
|
|
4007
4008
|
"attach_shared_set_to_campaigns": _apply_attach_shared_set_to_campaigns,
|
|
4008
4009
|
"detach_shared_set_from_campaigns": _apply_detach_shared_set_from_campaigns,
|
|
4009
4010
|
"add_demographic_criteria": _apply_add_demographic_criteria,
|
|
4011
|
+
"add_audience_criteria": _apply_audience_criteria,
|
|
4010
4012
|
"create_brand_list": _apply_create_brand_list,
|
|
4011
4013
|
"add_to_brand_list": _apply_add_to_brand_list,
|
|
4012
4014
|
"remove_from_brand_list": _apply_remove_from_brand_list,
|
|
@@ -60,6 +60,9 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
60
60
|
| `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) |
|
|
61
61
|
| `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
62
|
| `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` |
|
|
63
|
+
| `get_audience_lists` | The account's own audiences: remarketing and customer lists (type, OPEN/CLOSED, size for Search and Display, eligibility), custom segments, combined segments | (none) |
|
|
64
|
+
| `search_audience_segments` | Find Google's in-market and affinity segments by name (case-insensitive, English names) and get the `segment_id` to attach | `query`, `kind` (in_market/affinity/all), `limit` |
|
|
65
|
+
| `get_audience_targeting` | Audiences and audience exclusions attached to one campaign or ad group, the audience mode (observation/targeting/not set), and each one's `remove_id` | exactly one of `campaign_id` or `ad_group_id` |
|
|
63
66
|
| `suggest_brands` | Resolve one brand name to the brands Google recognizes — brand ID, name, state, URLs. The ID is what brand criteria target, so resolve here before building any brand list | `brand_prefix` (required), `selected_brand_ids` (optional, IDs already picked) |
|
|
64
67
|
| `check_brand_names` | Triage a shortlist of brand names at once: matched vs. unknown, plus each brand's ID | `brand_names` (required, max 25 per call) |
|
|
65
68
|
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) before creating a new one — reuse beats duplication | (none) |
|
|
@@ -79,6 +82,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
79
82
|
- PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API.
|
|
80
83
|
- `get_pmax_assets` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations.
|
|
81
84
|
- `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings.
|
|
85
|
+
- `get_audience_lists` flags lists below 1,000 users (Search ignores them until they grow) and closed lists. `get_audience_targeting`'s `audience_mode` is the entity's AUDIENCE setting: `observation` means the audiences only adjust bids and report, `targeting` means the ads only reach them, `not set` means none was written yet.
|
|
82
86
|
- `get_demographic_targeting` returns an empty list when no demographics have been excluded or narrowed — that is the DEFAULT state (Google serves to all segments). Each criterion includes a composite `remove_id` (`adGroupId~criterionId` or `campaignId~criterionId`) that can be passed straight to `remove_entity`.
|
|
83
87
|
- **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, budget-limited converters, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups, disapproved or limited ads) instead of every row; compact `get_ad_performance` keeps `final_urls` on its rows and adds `landing_pages` (every final URL across all ads with its ad count), so landing-page checks need no extra GAQL — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.
|
|
84
88
|
|
|
@@ -285,6 +289,7 @@ For images that have no public URL: a file on the user's computer, or an image t
|
|
|
285
289
|
| `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list |
|
|
286
290
|
| `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list |
|
|
287
291
|
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) |
|
|
292
|
+
| `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` |
|
|
288
293
|
| `draft_ai_max_settings` | Draft AI Max controls for one Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, text and final-URL asset automation. Refuses a plan that would switch AI Max on while an automation it was never told about keeps running; an explicitly requested automation asks for a double confirmation. | `campaign_id`, optional `enable_ai_max`, optional `disable_search_term_matching`, optional `ad_group_ids`, optional `include_paused_ad_groups`, optional `text_asset_automation`, optional `final_url_expansion` |
|
|
289
294
|
| `draft_prepare_brand_exclusions` | Draft the whole safe state for a brand exclusion in one step: AI Max on, search term matching off on every non-removed ad group, text and final-URL automation opted out. Touches nothing else. | `campaign_id`, optional `include_paused_ad_groups` |
|
|
290
295
|
| `pause_entity` | Propose pausing campaign/ad group/ad/keyword/asset group | `entity_type` (incl. `asset_group` for PMax), `entity_id` |
|
|
@@ -331,7 +336,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
331
336
|
- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool.
|
|
332
337
|
- `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
|
|
333
338
|
- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer.
|
|
334
|
-
- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link.
|
|
339
|
+
- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) or an attached audience or audience exclusion — pass the `remove_id` returned by `get_demographic_targeting` or `get_audience_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link.
|
|
335
340
|
- A Google Ads `dry_run=true` sends the exact change to Google with `validate_only` — Google checks it and executes nothing. `DRY_RUN_FAILED` carries Google's error and means the real apply would fail the same way; fix the cause and draft again. Steps that build on an object an earlier step would create (e.g. keywords for a new negative list) are counted in `checks.skipped_calls` instead of validated.
|
|
336
341
|
- `max_bid_increase_pct` (default 100) applies to Google Ads bid raises too: `update_ad_group` max_cpc on MANUAL_CPC and `update_campaign` max_cpc on an existing Maximize Clicks ceiling. A first-time ceiling has no baseline and is not capped.
|
|
337
342
|
- New Google Ads ad groups are created enabled; they cannot serve until one of their ads (created PAUSED) is enabled.
|
|
@@ -640,6 +645,17 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
640
645
|
|
|
641
646
|
- **Custom segments cannot be attached to Search campaigns.** A GAQL join of `custom_audience` against Search campaigns will always come back empty — that is expected, not missing data. Don't propose custom segments for Search; suggest in-market/affinity or remarketing lists instead.
|
|
642
647
|
- **PMax never takes hard audience targeting.** Every audience there is an asset-group-level signal — a hint to the algorithm, not a filter.
|
|
648
|
+
- Shopping campaigns take remarketing lists and in-market/affinity segments.
|
|
649
|
+
|
|
650
|
+
### When user wants to add, observe or exclude an audience ("add my remarketing list", "bid up for in-market software buyers", "exclude converters")
|
|
651
|
+
|
|
652
|
+
1. Find the audience: the account's own lists and segments with `get_audience_lists`; Google's in-market and affinity segments with `search_audience_segments` (English names: "software", not "Software-Käufer").
|
|
653
|
+
2. Check the campaign or ad group with `get_audience_targeting`: what is attached, and the current audience mode. Audiences live at one level per campaign: if the campaign has audiences, attach at the campaign; if its ad groups have them, attach there.
|
|
654
|
+
3. Default to **observation**. It keeps the reach and lets the audience adjust bids and show in reports, which is what "add an audience" almost always means. Only use `mode="targeting"` when the user explicitly wants to restrict who sees the ads, and say clearly that impressions and clicks will drop. The draft needs a double confirmation then.
|
|
655
|
+
4. Exclusions (`exclude=True`) are the safe way to keep people out, e.g. a list of recent buyers on a prospecting campaign. They don't change the mode.
|
|
656
|
+
5. Call `draft_audience_attachment`, show the preview with its warnings (mode changes, small lists), wait for approval, then `confirm_and_apply`.
|
|
657
|
+
6. To remove one, use `remove_entity` with the `remove_id` from `get_audience_targeting` (`entity_type` "campaign_criterion" or "ad_group_criterion").
|
|
658
|
+
7. Creating audiences (building remarketing lists or custom segments) is not supported; that happens in the Google Ads interface. Performance Max takes audiences as asset group signals (`draft_asset_group_signals`).
|
|
643
659
|
|
|
644
660
|
### When user asks to exclude or include demographics ("exclude ages 25-34", "exclude women", "only show to parents")
|
|
645
661
|
|
|
@@ -659,7 +675,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
659
675
|
8. **To remove an existing demographic exclusion**, call `get_demographic_targeting` to find the criterion's `remove_id`, then use `remove_entity` with `entity_type="ad_group_criterion"` or `entity_type="campaign_criterion"`.
|
|
660
676
|
|
|
661
677
|
**Common pitfalls:**
|
|
662
|
-
- "Audiences" and "demographics" mean different things in Google Ads. Audiences are user lists (remarketing, in-market, affinity) — use `
|
|
678
|
+
- "Audiences" and "demographics" mean different things in Google Ads. Audiences are user lists (remarketing, in-market, affinity) — use `get_audience_lists`, `get_audience_targeting`, `draft_audience_attachment` and `get_audience_performance`. Demographics are age/gender/parental/income — use `get_demographic_targeting` and `draft_demographic_targeting`. If the user says "exclude audiences" but describes ages, they mean demographics.
|
|
663
679
|
- Excluding many segments within a single dimension drastically reduces reach. The tool will warn when you exclude nearly all values in one dimension.
|
|
664
680
|
- **Excluding UNDETERMINED is almost never what the user wants.** Google cannot classify a large share of users (often 30%+, more under EU consent restrictions); excluding UNDETERMINED blocks all of them, far beyond the named segment. The tool warns — surface that warning prominently and confirm intent before applying.
|
|
665
681
|
- Campaign-level demographic criteria can only be EXCLUSIONS. Positive (narrowing) demographic targeting must go on an ad group — the tool rejects `campaign_id` + `negative=False`.
|
|
@@ -2668,6 +2668,131 @@ def run_gaql(
|
|
|
2668
2668
|
# ---------------------------------------------------------------------------
|
|
2669
2669
|
|
|
2670
2670
|
|
|
2671
|
+
@_tool(title="Audience lists", annotations=_READONLY, tags={"ads"})
|
|
2672
|
+
@_safe
|
|
2673
|
+
def get_audience_lists(customer_id: str = "") -> dict:
|
|
2674
|
+
"""The account's own audiences: remarketing and customer lists, custom segments, combined segments.
|
|
2675
|
+
|
|
2676
|
+
Each user list comes with its type, membership status (OPEN or CLOSED),
|
|
2677
|
+
size for Search and Display, eligibility and membership duration;
|
|
2678
|
+
custom and combined segments with their id and description. Insights
|
|
2679
|
+
flag lists below the 1,000 users Search needs and closed lists. Use the
|
|
2680
|
+
ids with draft_audience_attachment; for Google's in-market and affinity
|
|
2681
|
+
segments use search_audience_segments.
|
|
2682
|
+
|
|
2683
|
+
Args:
|
|
2684
|
+
customer_id: Google Ads customer ID (digits, dashes optional).
|
|
2685
|
+
Defaults to the configured account.
|
|
2686
|
+
"""
|
|
2687
|
+
from adloop.ads.audiences import get_audience_lists as _impl
|
|
2688
|
+
|
|
2689
|
+
return _impl(current_config(), customer_id=customer_id or current_config().ads.customer_id)
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
@_tool(title="Search audience segments", annotations=_READONLY, tags={"ads"})
|
|
2693
|
+
@_safe
|
|
2694
|
+
def search_audience_segments(query: str, kind: str = "all", limit: int = 25, customer_id: str = "") -> dict:
|
|
2695
|
+
"""Find Google's in-market and affinity segments by name, with the segment_id to attach.
|
|
2696
|
+
|
|
2697
|
+
In-market segments are people actively researching a purchase; affinity
|
|
2698
|
+
segments are long-term interests. Matching is on the segment name, in
|
|
2699
|
+
English, e.g. "running", "insurance", "travel".
|
|
2700
|
+
|
|
2701
|
+
Args:
|
|
2702
|
+
query: Part of the segment name to look for (at least 2 characters).
|
|
2703
|
+
kind: "in_market", "affinity" or "all" (default).
|
|
2704
|
+
limit: Maximum number of segments to return (1-100, default 25).
|
|
2705
|
+
customer_id: Google Ads customer ID (digits, dashes optional).
|
|
2706
|
+
Defaults to the configured account.
|
|
2707
|
+
"""
|
|
2708
|
+
from adloop.ads.audiences import search_audience_segments as _impl
|
|
2709
|
+
|
|
2710
|
+
return _impl(current_config(), customer_id=customer_id or current_config().ads.customer_id, query=query, kind=kind, limit=limit)
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
@_tool(title="Audience targeting", annotations=_READONLY, tags={"ads"})
|
|
2714
|
+
@_safe
|
|
2715
|
+
def get_audience_targeting(campaign_id: str = "", ad_group_id: str = "", customer_id: str = "") -> dict:
|
|
2716
|
+
"""Audiences attached to one campaign or ad group, its exclusions, and the audience mode.
|
|
2717
|
+
|
|
2718
|
+
audience_mode is "observation" (audiences only adjust bids and report),
|
|
2719
|
+
"targeting" (ads only reach the audiences) or "not set". Every audience
|
|
2720
|
+
and exclusion carries a remove_id for remove_entity (entity_type
|
|
2721
|
+
"campaign_criterion" or "ad_group_criterion").
|
|
2722
|
+
|
|
2723
|
+
Args:
|
|
2724
|
+
campaign_id: Numeric campaign id. Pass this or ad_group_id.
|
|
2725
|
+
ad_group_id: Numeric ad group id. Pass this or campaign_id.
|
|
2726
|
+
customer_id: Google Ads customer ID (digits, dashes optional).
|
|
2727
|
+
Defaults to the configured account.
|
|
2728
|
+
"""
|
|
2729
|
+
from adloop.ads.audiences import get_audience_targeting as _impl
|
|
2730
|
+
|
|
2731
|
+
return _impl(
|
|
2732
|
+
current_config(),
|
|
2733
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
2734
|
+
campaign_id=campaign_id,
|
|
2735
|
+
ad_group_id=ad_group_id,
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
|
|
2739
|
+
@_tool(title="Draft audience attachment", annotations=_WRITE, tags={"ads"})
|
|
2740
|
+
@_safe
|
|
2741
|
+
def draft_audience_attachment(
|
|
2742
|
+
campaign_id: str = "",
|
|
2743
|
+
ad_group_id: str = "",
|
|
2744
|
+
user_list_ids: _StrList = [], # noqa: B006
|
|
2745
|
+
segment_ids: _StrList = [], # noqa: B006
|
|
2746
|
+
custom_audience_ids: _StrList = [], # noqa: B006
|
|
2747
|
+
combined_audience_ids: _StrList = [], # noqa: B006
|
|
2748
|
+
mode: str = "observation",
|
|
2749
|
+
exclude: bool = False,
|
|
2750
|
+
customer_id: str = "",
|
|
2751
|
+
) -> dict:
|
|
2752
|
+
"""Draft attaching audiences to a campaign or ad group as observation, targeting or exclusion: returns a PREVIEW.
|
|
2753
|
+
|
|
2754
|
+
Observation (the default) keeps the reach: the audiences adjust bids and
|
|
2755
|
+
show up in reports. Targeting narrows delivery to the audiences and needs
|
|
2756
|
+
a double confirmation. Exclusions keep the people out and leave the mode
|
|
2757
|
+
alone. The draft refuses combinations Google does not allow (custom
|
|
2758
|
+
segments on Search, combined segments outside Search, audiences at both
|
|
2759
|
+
campaign and ad group level) and lists that cannot serve; Performance Max
|
|
2760
|
+
is refused with a pointer to draft_asset_group_signals. Nothing changes
|
|
2761
|
+
until confirm_and_apply applies the plan_id; a dry run has Google
|
|
2762
|
+
validate it.
|
|
2763
|
+
|
|
2764
|
+
Args:
|
|
2765
|
+
campaign_id: Numeric campaign id. Pass this or ad_group_id.
|
|
2766
|
+
ad_group_id: Numeric ad group id. Pass this or campaign_id.
|
|
2767
|
+
user_list_ids: Remarketing or customer lists (user_list_id from
|
|
2768
|
+
get_audience_lists).
|
|
2769
|
+
segment_ids: In-market or affinity segments (segment_id from
|
|
2770
|
+
search_audience_segments).
|
|
2771
|
+
custom_audience_ids: Custom segments (custom_audience_id from
|
|
2772
|
+
get_audience_lists); Display and Video only.
|
|
2773
|
+
combined_audience_ids: Combined segments (combined_audience_id from
|
|
2774
|
+
get_audience_lists); Search only.
|
|
2775
|
+
mode: "observation" (default) or "targeting". Ignored for exclusions.
|
|
2776
|
+
exclude: True to exclude the audiences instead of adding them.
|
|
2777
|
+
customer_id: Google Ads customer ID (digits, dashes optional).
|
|
2778
|
+
Defaults to the configured account.
|
|
2779
|
+
"""
|
|
2780
|
+
from adloop.ads.audiences import draft_audience_attachment as _impl
|
|
2781
|
+
|
|
2782
|
+
return _impl(
|
|
2783
|
+
current_config(),
|
|
2784
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
2785
|
+
campaign_id=campaign_id,
|
|
2786
|
+
ad_group_id=ad_group_id,
|
|
2787
|
+
user_list_ids=user_list_ids,
|
|
2788
|
+
segment_ids=segment_ids,
|
|
2789
|
+
custom_audience_ids=custom_audience_ids,
|
|
2790
|
+
combined_audience_ids=combined_audience_ids,
|
|
2791
|
+
mode=mode,
|
|
2792
|
+
exclude=exclude,
|
|
2793
|
+
)
|
|
2794
|
+
|
|
2795
|
+
|
|
2671
2796
|
@_tool(title="Asset group signals", annotations=_READONLY, tags={"ads"})
|
|
2672
2797
|
@_safe
|
|
2673
2798
|
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
|
|
File without changes
|