adloop 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """AdLoop — MCP server connecting Google Ads + GA4 + codebase."""
2
+
3
+ import sys
4
+
5
+ __version__ = "0.1.0"
6
+
7
+
8
+ def main() -> None:
9
+ """Entry point for `adloop` console script.
10
+
11
+ Routes to the setup wizard when called as ``adloop init``,
12
+ otherwise starts the MCP server.
13
+ """
14
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
15
+ from adloop.cli import run_init_wizard
16
+
17
+ run_init_wizard()
18
+ else:
19
+ from adloop.server import mcp
20
+
21
+ mcp.run()
adloop/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m adloop`."""
2
+
3
+ from adloop import main
4
+
5
+ main()
adloop/ads/__init__.py ADDED
File without changes
adloop/ads/client.py ADDED
@@ -0,0 +1,41 @@
1
+ """Google Ads API client wrapper — thin layer over the google-ads library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from google.ads.googleads.client import GoogleAdsClient
9
+
10
+ from adloop.config import AdLoopConfig
11
+
12
+ # Pin the API version so library upgrades don't silently break field names,
13
+ # enum values, or mutate operation structures. Bump this deliberately when
14
+ # migrating to a new API version — never let it float to the library default.
15
+ GOOGLE_ADS_API_VERSION = "v23"
16
+
17
+
18
+ def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
19
+ """Return an authenticated Google Ads API client pinned to a specific API version."""
20
+ from google.ads.googleads.client import GoogleAdsClient
21
+
22
+ from adloop.auth import get_ads_credentials
23
+
24
+ credentials = get_ads_credentials(config)
25
+
26
+ client_config = {
27
+ "developer_token": config.ads.developer_token,
28
+ "use_proto_plus": True,
29
+ "version": GOOGLE_ADS_API_VERSION,
30
+ }
31
+
32
+ if config.ads.login_customer_id:
33
+ client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
34
+
35
+ client = GoogleAdsClient(credentials=credentials, **client_config)
36
+ return client
37
+
38
+
39
+ def normalize_customer_id(customer_id: str) -> str:
40
+ """Strip dashes from customer ID for API calls (123-456-7890 -> 1234567890)."""
41
+ return customer_id.replace("-", "")
adloop/ads/forecast.py ADDED
@@ -0,0 +1,156 @@
1
+ """Budget estimation via Google Ads Keyword Planner forecast metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from adloop.config import AdLoopConfig
10
+
11
+
12
+ _DEFAULT_MAX_CPC_MICROS = 1_000_000 # 1.00 in account currency
13
+
14
+
15
+ def estimate_budget(
16
+ config: AdLoopConfig,
17
+ *,
18
+ keywords: list[dict],
19
+ daily_budget: float = 0,
20
+ geo_target_id: str = "2276",
21
+ language_id: str = "1000",
22
+ forecast_days: int = 30,
23
+ customer_id: str = "",
24
+ ) -> dict:
25
+ """Forecast clicks, impressions, and cost for a set of keywords.
26
+
27
+ Uses KeywordPlanIdeaService.GenerateKeywordForecastMetrics to estimate
28
+ campaign performance without creating anything. Useful for budget planning
29
+ before launching a new campaign.
30
+
31
+ keywords: list of {"text": str, "match_type": "EXACT|PHRASE|BROAD", "max_cpc": float (optional)}
32
+ geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK, 2250=France)
33
+ language_id: language constant (1000=English, 1001=German, 1002=French, 1003=Spanish)
34
+ forecast_days: number of days to forecast (default 30)
35
+ """
36
+ from adloop.ads.client import get_ads_client, normalize_customer_id
37
+
38
+ if not keywords:
39
+ return {"error": "At least one keyword is required"}
40
+
41
+ client = get_ads_client(config)
42
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
43
+
44
+ googleads_service = client.get_service("GoogleAdsService")
45
+ kp_service = client.get_service("KeywordPlanIdeaService")
46
+
47
+ campaign = client.get_type("CampaignToForecast")
48
+ campaign.keyword_plan_network = (
49
+ client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
50
+ )
51
+
52
+ max_bid = max(
53
+ (int(kw.get("max_cpc", 0) * 1_000_000) for kw in keywords),
54
+ default=_DEFAULT_MAX_CPC_MICROS,
55
+ )
56
+ if max_bid <= 0:
57
+ max_bid = _DEFAULT_MAX_CPC_MICROS
58
+ campaign.bidding_strategy.manual_cpc_bidding_strategy.max_cpc_bid_micros = max_bid
59
+
60
+ geo_modifier = client.get_type("CriterionBidModifier")
61
+ geo_modifier.geo_target_constant = googleads_service.geo_target_constant_path(
62
+ geo_target_id
63
+ )
64
+ campaign.geo_modifiers.append(geo_modifier)
65
+
66
+ campaign.language_constants.append(
67
+ googleads_service.language_constant_path(language_id)
68
+ )
69
+
70
+ ad_group = client.get_type("ForecastAdGroup")
71
+
72
+ for kw in keywords:
73
+ text = kw.get("text", "")
74
+ if not text:
75
+ continue
76
+ match_type = kw.get("match_type", "BROAD").upper()
77
+ cpc_micros = int(kw.get("max_cpc", 0) * 1_000_000) or _DEFAULT_MAX_CPC_MICROS
78
+
79
+ biddable = client.get_type("BiddableKeyword")
80
+ biddable.max_cpc_bid_micros = cpc_micros
81
+ biddable.keyword.text = text
82
+ biddable.keyword.match_type = getattr(
83
+ client.enums.KeywordMatchTypeEnum, match_type, client.enums.KeywordMatchTypeEnum.BROAD
84
+ )
85
+ ad_group.biddable_keywords.append(biddable)
86
+
87
+ campaign.ad_groups.append(ad_group)
88
+
89
+ request = client.get_type("GenerateKeywordForecastMetricsRequest")
90
+ request.customer_id = cid
91
+ request.campaign = campaign
92
+
93
+ tomorrow = date.today() + timedelta(days=1)
94
+ end_date = date.today() + timedelta(days=forecast_days)
95
+ request.forecast_period.start_date = tomorrow.isoformat()
96
+ request.forecast_period.end_date = end_date.isoformat()
97
+
98
+ response = kp_service.generate_keyword_forecast_metrics(request=request)
99
+ metrics = response.campaign_forecast_metrics
100
+
101
+ clicks = getattr(metrics, "clicks", None)
102
+ impressions = getattr(metrics, "impressions", None)
103
+ avg_cpc_micros = getattr(metrics, "average_cpc_micros", None)
104
+ cost_micros = getattr(metrics, "cost_micros", None)
105
+ ctr = getattr(metrics, "click_through_rate", None)
106
+
107
+ total_cost = round(cost_micros / 1_000_000, 2) if cost_micros else None
108
+ avg_cpc = round(avg_cpc_micros / 1_000_000, 2) if avg_cpc_micros else None
109
+
110
+ days = max(forecast_days, 1)
111
+ daily = {
112
+ "clicks": round(clicks / days, 1) if clicks else None,
113
+ "impressions": round(impressions / days, 1) if impressions else None,
114
+ "cost": round(total_cost / days, 2) if total_cost else None,
115
+ }
116
+
117
+ insights = []
118
+ if total_cost is not None and clicks is not None and clicks > 0:
119
+ effective_cpa_budget = total_cost / clicks * 10
120
+ insights.append(
121
+ f"Estimated {clicks:.0f} clicks over {forecast_days} days at "
122
+ f"~{avg_cpc} avg CPC. Total estimated cost: {total_cost:.2f}."
123
+ )
124
+ if daily_budget > 0 and daily["cost"] is not None:
125
+ if daily_budget < daily["cost"]:
126
+ capture_pct = round(daily_budget / daily["cost"] * 100)
127
+ insights.append(
128
+ f"Daily budget of {daily_budget:.2f} would capture ~{capture_pct}% "
129
+ f"of available traffic (estimated daily cost: {daily['cost']:.2f})."
130
+ )
131
+ else:
132
+ insights.append(
133
+ f"Daily budget of {daily_budget:.2f} is sufficient to capture "
134
+ f"most available traffic (estimated daily cost: {daily['cost']:.2f})."
135
+ )
136
+
137
+ if impressions is not None and clicks is not None and impressions > 0 and clicks == 0:
138
+ insights.append(
139
+ "Forecast shows impressions but zero clicks — keywords may be too "
140
+ "generic or CPCs too low for competitive positions."
141
+ )
142
+
143
+ return {
144
+ "forecast_period": {
145
+ "start": tomorrow.isoformat(),
146
+ "end": end_date.isoformat(),
147
+ },
148
+ "estimated_clicks": clicks,
149
+ "estimated_impressions": impressions,
150
+ "estimated_cost": total_cost,
151
+ "estimated_avg_cpc": avg_cpc,
152
+ "estimated_ctr": round(ctr, 4) if ctr else None,
153
+ "daily_estimates": daily,
154
+ "keywords_used": len([kw for kw in keywords if kw.get("text")]),
155
+ "insights": insights,
156
+ }
adloop/ads/gaql.py ADDED
@@ -0,0 +1,178 @@
1
+ """GAQL (Google Ads Query Language) tool — run arbitrary queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from adloop.config import AdLoopConfig
10
+
11
+
12
+ def execute_query(
13
+ config: AdLoopConfig, customer_id: str, query: str
14
+ ) -> list[dict]:
15
+ """Execute a GAQL query and return results as a list of flat dicts.
16
+
17
+ Shared by run_gaql (the MCP tool) and the individual read tools
18
+ in ads/read.py.
19
+ """
20
+ from adloop.ads.client import get_ads_client, normalize_customer_id
21
+
22
+ client = get_ads_client(config)
23
+ service = client.get_service("GoogleAdsService")
24
+ cid = normalize_customer_id(customer_id)
25
+
26
+ fields = _parse_select_fields(query)
27
+
28
+ rows = []
29
+ for row in service.search(customer_id=cid, query=query):
30
+ r = {}
31
+ for field in fields:
32
+ r[field] = _extract_field(row, field)
33
+ rows.append(r)
34
+
35
+ return rows
36
+
37
+
38
+ def run_gaql(
39
+ config: AdLoopConfig,
40
+ *,
41
+ customer_id: str = "",
42
+ query: str = "",
43
+ format: str = "table",
44
+ ) -> dict:
45
+ """Execute an arbitrary GAQL query and return formatted results."""
46
+ if not query:
47
+ return {"error": "Query string is required."}
48
+
49
+ try:
50
+ rows = execute_query(config, customer_id, query)
51
+ except Exception as e:
52
+ return {"error": _parse_gaql_error(e), "query": query}
53
+
54
+ if format == "table":
55
+ return _format_table(rows, query)
56
+ elif format == "csv":
57
+ return _format_csv(rows, query)
58
+ return {"rows": rows, "row_count": len(rows), "query": query}
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Internal helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+ _GAQL_ERROR_HINTS = {
66
+ "EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE": (
67
+ "Fields used in ORDER BY or HAVING must also appear in the SELECT clause. "
68
+ "Add the missing field to your SELECT."
69
+ ),
70
+ "UNRECOGNIZED_FIELD": "Check the field name — it may be misspelled or not available on this resource.",
71
+ "INVALID_RESOURCE_NAME": "The resource name in FROM is invalid. Check GAQL resource names.",
72
+ "PROHIBITED_FIELD_COMBINATION": (
73
+ "Some fields can't be selected together. Metrics and certain resource fields "
74
+ "may conflict. Try splitting into separate queries."
75
+ ),
76
+ "QUERY_NOT_ALLOWED": "This query type is not supported for this resource or access level.",
77
+ "INVALID_ARGUMENT": "Check your WHERE clause values — date formats, status strings, and IDs must be valid.",
78
+ }
79
+
80
+
81
+ def _parse_gaql_error(exc: Exception) -> str:
82
+ """Extract a human-readable message from Google Ads gRPC errors."""
83
+ raw = str(exc)
84
+ for code, hint in _GAQL_ERROR_HINTS.items():
85
+ if code in raw:
86
+ return f"{code}: {hint}"
87
+ if len(raw) > 500:
88
+ return raw[:500] + "..."
89
+ return raw
90
+
91
+
92
+ def _parse_select_fields(query: str) -> list[str]:
93
+ """Extract field names from the SELECT clause of a GAQL query."""
94
+ match = re.search(r"SELECT\s+(.*?)\s+FROM", query, re.IGNORECASE | re.DOTALL)
95
+ if not match:
96
+ return []
97
+ return [f.strip() for f in match.group(1).split(",") if f.strip()]
98
+
99
+
100
+ def _extract_field(row: object, field_path: str) -> object:
101
+ """Walk a dotted field path on a proto-plus GoogleAdsRow."""
102
+ obj = row
103
+ for part in field_path.split("."):
104
+ try:
105
+ obj = getattr(obj, part)
106
+ except AttributeError:
107
+ return None
108
+ return _to_python(obj)
109
+
110
+
111
+ def _to_python(obj: object) -> object:
112
+ """Convert proto-plus / protobuf values to plain Python types."""
113
+ if obj is None:
114
+ return None
115
+ if isinstance(obj, (str, float, bool)):
116
+ return obj
117
+ if isinstance(obj, int):
118
+ # Proto-plus enums are int subclasses with a .name attribute
119
+ if type(obj) is not int and hasattr(obj, "name"):
120
+ return obj.name
121
+ return obj
122
+ # Repeated fields (headlines, final_urls, etc.)
123
+ try:
124
+ return [_to_python(item) for item in obj]
125
+ except TypeError:
126
+ pass
127
+ # AdTextAsset and similar message types
128
+ if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str):
129
+ return obj.text
130
+ return str(obj)
131
+
132
+
133
+ def _format_table(rows: list[dict], query: str) -> dict:
134
+ """Format query results as an aligned text table."""
135
+ if not rows:
136
+ return {"table": "(no results)", "row_count": 0, "query": query}
137
+
138
+ headers = list(rows[0].keys())
139
+ widths = {h: len(h) for h in headers}
140
+
141
+ str_rows = []
142
+ for row in rows:
143
+ sr = {}
144
+ for h in headers:
145
+ val = row.get(h)
146
+ if isinstance(val, list):
147
+ s = ", ".join(str(v) for v in val)
148
+ else:
149
+ s = str(val) if val is not None else ""
150
+ sr[h] = s
151
+ widths[h] = max(widths[h], len(s))
152
+ str_rows.append(sr)
153
+
154
+ header_line = " | ".join(h.ljust(widths[h]) for h in headers)
155
+ separator = "-+-".join("-" * widths[h] for h in headers)
156
+ data_lines = [
157
+ " | ".join(sr[h].ljust(widths[h]) for h in headers) for sr in str_rows
158
+ ]
159
+
160
+ table = "\n".join([header_line, separator, *data_lines])
161
+ return {"table": table, "row_count": len(rows), "query": query}
162
+
163
+
164
+ def _format_csv(rows: list[dict], query: str) -> dict:
165
+ """Format query results as CSV."""
166
+ if not rows:
167
+ return {"csv": "", "row_count": 0, "query": query}
168
+
169
+ import csv
170
+ import io
171
+
172
+ output = io.StringIO()
173
+ writer = csv.DictWriter(output, fieldnames=rows[0].keys())
174
+ writer.writeheader()
175
+ for row in rows:
176
+ writer.writerow({k: v if not isinstance(v, list) else "; ".join(str(i) for i in v) for k, v in row.items()})
177
+
178
+ return {"csv": output.getvalue(), "row_count": len(rows), "query": query}
adloop/ads/read.py ADDED
@@ -0,0 +1,238 @@
1
+ """Google Ads read tools — campaign, ad, keyword, and search term performance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from adloop.config import AdLoopConfig
9
+
10
+
11
+ def list_accounts(config: AdLoopConfig) -> dict:
12
+ """List all accessible Google Ads accounts."""
13
+ from adloop.ads.gaql import execute_query
14
+
15
+ mcc_id = config.ads.login_customer_id
16
+ if mcc_id:
17
+ query = """
18
+ SELECT customer_client.id, customer_client.descriptive_name,
19
+ customer_client.status, customer_client.manager
20
+ FROM customer_client
21
+ """
22
+ rows = execute_query(config, mcc_id, query)
23
+ else:
24
+ query = """
25
+ SELECT customer.id, customer.descriptive_name,
26
+ customer.status, customer.manager
27
+ FROM customer
28
+ LIMIT 1
29
+ """
30
+ rows = execute_query(config, config.ads.customer_id, query)
31
+
32
+ return {"accounts": rows, "total_accounts": len(rows)}
33
+
34
+
35
+ def get_campaign_performance(
36
+ config: AdLoopConfig,
37
+ *,
38
+ customer_id: str = "",
39
+ date_range_start: str = "",
40
+ date_range_end: str = "",
41
+ ) -> dict:
42
+ """Get campaign-level performance metrics for the given date range."""
43
+ from adloop.ads.gaql import execute_query
44
+
45
+ date_clause = _date_clause(date_range_start, date_range_end)
46
+
47
+ query = f"""
48
+ SELECT campaign.id, campaign.name, campaign.status,
49
+ campaign.advertising_channel_type, campaign.bidding_strategy_type,
50
+ metrics.impressions, metrics.clicks, metrics.cost_micros,
51
+ metrics.conversions, metrics.conversions_value,
52
+ metrics.ctr, metrics.average_cpc
53
+ FROM campaign
54
+ WHERE campaign.status != 'REMOVED'
55
+ {date_clause}
56
+ ORDER BY metrics.cost_micros DESC
57
+ """
58
+
59
+ rows = execute_query(config, customer_id, query)
60
+ _enrich_cost_fields(rows)
61
+
62
+ return {"campaigns": rows, "total_campaigns": len(rows)}
63
+
64
+
65
+ def get_ad_performance(
66
+ config: AdLoopConfig,
67
+ *,
68
+ customer_id: str = "",
69
+ date_range_start: str = "",
70
+ date_range_end: str = "",
71
+ ) -> dict:
72
+ """Get ad-level performance data including headlines, descriptions, and metrics."""
73
+ from adloop.ads.gaql import execute_query
74
+
75
+ date_clause = _date_clause(date_range_start, date_range_end)
76
+
77
+ query = f"""
78
+ SELECT campaign.name, ad_group.name,
79
+ ad_group_ad.ad.id, ad_group_ad.ad.type,
80
+ ad_group_ad.ad.responsive_search_ad.headlines,
81
+ ad_group_ad.ad.responsive_search_ad.descriptions,
82
+ ad_group_ad.ad.final_urls,
83
+ ad_group_ad.status,
84
+ metrics.impressions, metrics.clicks, metrics.ctr,
85
+ metrics.conversions, metrics.cost_micros
86
+ FROM ad_group_ad
87
+ WHERE ad_group_ad.status != 'REMOVED'
88
+ {date_clause}
89
+ ORDER BY metrics.cost_micros DESC
90
+ """
91
+
92
+ rows = execute_query(config, customer_id, query)
93
+ _enrich_cost_fields(rows)
94
+
95
+ return {"ads": rows, "total_ads": len(rows)}
96
+
97
+
98
+ def get_keyword_performance(
99
+ config: AdLoopConfig,
100
+ *,
101
+ customer_id: str = "",
102
+ date_range_start: str = "",
103
+ date_range_end: str = "",
104
+ ) -> dict:
105
+ """Get keyword metrics including quality scores and competitive data."""
106
+ from adloop.ads.gaql import execute_query
107
+
108
+ date_clause = _date_clause(date_range_start, date_range_end)
109
+
110
+ query = f"""
111
+ SELECT campaign.name, ad_group.name,
112
+ ad_group_criterion.keyword.text,
113
+ ad_group_criterion.keyword.match_type,
114
+ ad_group_criterion.quality_info.quality_score,
115
+ metrics.impressions, metrics.clicks, metrics.ctr,
116
+ metrics.average_cpc, metrics.cost_micros,
117
+ metrics.conversions
118
+ FROM keyword_view
119
+ WHERE ad_group_criterion.status != 'REMOVED'
120
+ {date_clause}
121
+ ORDER BY metrics.cost_micros DESC
122
+ """
123
+
124
+ rows = execute_query(config, customer_id, query)
125
+ _enrich_cost_fields(rows)
126
+
127
+ return {"keywords": rows, "total_keywords": len(rows)}
128
+
129
+
130
+ def get_search_terms(
131
+ config: AdLoopConfig,
132
+ *,
133
+ customer_id: str = "",
134
+ date_range_start: str = "",
135
+ date_range_end: str = "",
136
+ ) -> dict:
137
+ """Get search terms report — what users actually typed before clicking ads."""
138
+ from adloop.ads.gaql import execute_query
139
+
140
+ date_clause = _date_clause(date_range_start, date_range_end)
141
+
142
+ query = f"""
143
+ SELECT search_term_view.search_term,
144
+ campaign.name, ad_group.name,
145
+ metrics.impressions, metrics.clicks,
146
+ metrics.cost_micros, metrics.conversions
147
+ FROM search_term_view
148
+ WHERE segments.date DURING LAST_30_DAYS
149
+ {f"AND segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'" if date_range_start and date_range_end else ""}
150
+ ORDER BY metrics.clicks DESC
151
+ LIMIT 200
152
+ """
153
+ # search_term_view requires an explicit date segment, so we always
154
+ # include DURING LAST_30_DAYS as baseline and override if dates given.
155
+ if date_range_start and date_range_end:
156
+ query = f"""
157
+ SELECT search_term_view.search_term,
158
+ campaign.name, ad_group.name,
159
+ metrics.impressions, metrics.clicks,
160
+ metrics.cost_micros, metrics.conversions
161
+ FROM search_term_view
162
+ WHERE segments.date BETWEEN '{date_range_start}' AND '{date_range_end}'
163
+ ORDER BY metrics.clicks DESC
164
+ LIMIT 200
165
+ """
166
+ else:
167
+ query = """
168
+ SELECT search_term_view.search_term,
169
+ campaign.name, ad_group.name,
170
+ metrics.impressions, metrics.clicks,
171
+ metrics.cost_micros, metrics.conversions
172
+ FROM search_term_view
173
+ WHERE segments.date DURING LAST_30_DAYS
174
+ ORDER BY metrics.clicks DESC
175
+ LIMIT 200
176
+ """
177
+
178
+ rows = execute_query(config, customer_id, query)
179
+ _enrich_cost_fields(rows)
180
+
181
+ return {"search_terms": rows, "total_search_terms": len(rows)}
182
+
183
+
184
+ def get_negative_keywords(
185
+ config: AdLoopConfig,
186
+ *,
187
+ customer_id: str = "",
188
+ campaign_id: str = "",
189
+ ) -> dict:
190
+ """List negative keywords for a campaign or all campaigns."""
191
+ from adloop.ads.gaql import execute_query
192
+
193
+ campaign_filter = ""
194
+ if campaign_id:
195
+ campaign_filter = f"AND campaign.id = {campaign_id}"
196
+
197
+ query = f"""
198
+ SELECT campaign.id, campaign.name,
199
+ campaign_criterion.keyword.text,
200
+ campaign_criterion.keyword.match_type,
201
+ campaign_criterion.negative,
202
+ campaign_criterion.criterion_id
203
+ FROM campaign_criterion
204
+ WHERE campaign_criterion.negative = TRUE
205
+ AND campaign_criterion.status != 'REMOVED'
206
+ {campaign_filter}
207
+ ORDER BY campaign.name
208
+ """
209
+
210
+ rows = execute_query(config, customer_id, query)
211
+ return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Internal helpers
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _date_clause(start: str, end: str) -> str:
220
+ """Build a GAQL date WHERE fragment."""
221
+ if start and end:
222
+ return f"AND segments.date BETWEEN '{start}' AND '{end}'"
223
+ return "AND segments.date DURING LAST_30_DAYS"
224
+
225
+
226
+ def _enrich_cost_fields(rows: list[dict]) -> None:
227
+ """Add human-readable cost and CPA fields computed from cost_micros."""
228
+ for row in rows:
229
+ cost_micros = row.get("metrics.cost_micros", 0) or 0
230
+ row["metrics.cost"] = round(cost_micros / 1_000_000, 2)
231
+
232
+ conversions = row.get("metrics.conversions", 0) or 0
233
+ if conversions > 0:
234
+ row["metrics.cpa"] = round(cost_micros / 1_000_000 / conversions, 2)
235
+
236
+ avg_cpc_micros = row.get("metrics.average_cpc", 0) or 0
237
+ if avg_cpc_micros:
238
+ row["metrics.average_cpc_eur"] = round(avg_cpc_micros / 1_000_000, 2)