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 +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
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
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)
|