adloop 0.35.1__tar.gz → 0.36.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.35.1 → adloop-0.36.0}/PKG-INFO +5 -3
- {adloop-0.35.1 → adloop-0.36.0}/README.md +4 -2
- {adloop-0.35.1 → adloop-0.36.0}/pyproject.toml +1 -1
- {adloop-0.35.1 → adloop-0.36.0}/pyproject.toml.orig +1 -1
- adloop-0.36.0/src/adloop/ads/campaign_settings.py +222 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/read.py +4 -0
- adloop-0.36.0/src/adloop/ads/recommendations.py +210 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/write.py +15 -2
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/cli.py +10 -7
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/adloop.md +4 -1
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules_install.py +66 -22
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/server.py +82 -4
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/__main__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/assets.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/snippet_headers.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/auth.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/config.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/crossref.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/runtime.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/tracking.py +0 -0
- {adloop-0.35.1 → adloop-0.36.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.35.1 → adloop-0.36.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.36.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
|
|
@@ -148,6 +148,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
148
148
|
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
149
149
|
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
150
150
|
| `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
|
|
151
|
+
| `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
|
|
151
152
|
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
152
153
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
153
154
|
|
|
@@ -334,6 +335,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
334
335
|
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
335
336
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
336
337
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
338
|
+
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
337
339
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
338
340
|
| `enable_entity` | Re-enable a paused entity |
|
|
339
341
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -523,8 +525,8 @@ adloop install-rules
|
|
|
523
525
|
|
|
524
526
|
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
525
527
|
|
|
526
|
-
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but
|
|
527
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
528
|
+
- **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
|
|
529
|
+
- **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
|
|
528
530
|
|
|
529
531
|
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
530
532
|
|
|
@@ -120,6 +120,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
120
120
|
| `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
|
|
121
121
|
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
|
|
122
122
|
| `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
|
|
123
|
+
| `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
|
|
123
124
|
| `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
|
|
124
125
|
| `run_gaql` | Arbitrary GAQL queries for anything else |
|
|
125
126
|
|
|
@@ -306,6 +307,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
306
307
|
| `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
|
|
307
308
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
308
309
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
310
|
+
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
309
311
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
310
312
|
| `enable_entity` | Re-enable a paused entity |
|
|
311
313
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -495,8 +497,8 @@ adloop install-rules
|
|
|
495
497
|
|
|
496
498
|
This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
|
|
497
499
|
|
|
498
|
-
- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but
|
|
499
|
-
- **lazy** (`adloop install-rules --lazy`) —
|
|
500
|
+
- **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
|
|
501
|
+
- **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
|
|
500
502
|
|
|
501
503
|
To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
|
|
502
504
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""One read for the settings that decide how far a Search campaign reaches.
|
|
2
|
+
|
|
3
|
+
A recurring audit ("has Google switched anything back on?") otherwise needs
|
|
4
|
+
``get_ai_max_settings`` plus hand-written GAQL for networks, location option,
|
|
5
|
+
campaign-level broad match and URL options. Every field below was checked
|
|
6
|
+
against the v25 ``campaign`` resource:
|
|
7
|
+
|
|
8
|
+
campaign.network_settings.target_google_search / target_search_network /
|
|
9
|
+
target_content_network / target_partner_search_network
|
|
10
|
+
campaign.geo_target_type_setting.positive_geo_target_type PRESENCE_OR_INTEREST,
|
|
11
|
+
SEARCH_INTEREST, PRESENCE
|
|
12
|
+
campaign.geo_target_type_setting.negative_geo_target_type PRESENCE_OR_INTEREST, PRESENCE
|
|
13
|
+
campaign.keyword_match_type BROAD, or unset
|
|
14
|
+
campaign.tracking_url_template, final_url_suffix, url_custom_parameters
|
|
15
|
+
campaign.bidding_strategy_type and the per-strategy targets
|
|
16
|
+
campaign_budget.amount_micros, explicitly_shared
|
|
17
|
+
|
|
18
|
+
The AI Max half comes from ``ai_max.read_ai_max_state`` unchanged, so both tools
|
|
19
|
+
always agree. Read-only: nothing here mutates.
|
|
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
|
+
_SETTINGS_SELECT = """
|
|
30
|
+
SELECT campaign.id, campaign.name, campaign.status,
|
|
31
|
+
campaign.advertising_channel_type,
|
|
32
|
+
campaign.bidding_strategy_type, campaign.bidding_strategy,
|
|
33
|
+
campaign.target_spend.cpc_bid_ceiling_micros,
|
|
34
|
+
campaign.maximize_conversions.target_cpa_micros,
|
|
35
|
+
campaign.target_cpa.target_cpa_micros,
|
|
36
|
+
campaign.target_roas.target_roas,
|
|
37
|
+
campaign.maximize_conversion_value.target_roas,
|
|
38
|
+
campaign.network_settings.target_google_search,
|
|
39
|
+
campaign.network_settings.target_search_network,
|
|
40
|
+
campaign.network_settings.target_content_network,
|
|
41
|
+
campaign.geo_target_type_setting.positive_geo_target_type,
|
|
42
|
+
campaign.geo_target_type_setting.negative_geo_target_type,
|
|
43
|
+
campaign.keyword_match_type,
|
|
44
|
+
campaign.tracking_url_template, campaign.final_url_suffix,
|
|
45
|
+
campaign.url_custom_parameters,
|
|
46
|
+
campaign_budget.amount_micros, campaign_budget.explicitly_shared
|
|
47
|
+
FROM campaign
|
|
48
|
+
WHERE {where}
|
|
49
|
+
ORDER BY campaign.name
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_SEARCH_ONLY = "campaign.advertising_channel_type = 'SEARCH'"
|
|
53
|
+
_WHERE_ALL = f"campaign.status != 'REMOVED' AND {_SEARCH_ONLY}"
|
|
54
|
+
|
|
55
|
+
# Google's default; it also reaches people elsewhere who search *about* the
|
|
56
|
+
# targeted locations.
|
|
57
|
+
_WIDE_LOCATION_OPTIONS = {"PRESENCE_OR_INTEREST", "SEARCH_INTEREST"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _money(micros: object) -> float | None:
|
|
61
|
+
"""Micros to account currency; 0 or missing means "not set"."""
|
|
62
|
+
if not micros:
|
|
63
|
+
return None
|
|
64
|
+
return round(int(micros) / 1_000_000, 2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_cpa(row: dict) -> float | None:
|
|
68
|
+
return _money(
|
|
69
|
+
row.get("campaign.target_cpa.target_cpa_micros")
|
|
70
|
+
or row.get("campaign.maximize_conversions.target_cpa_micros")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _target_roas(row: dict) -> float | None:
|
|
75
|
+
value = row.get("campaign.target_roas.target_roas") or row.get(
|
|
76
|
+
"campaign.maximize_conversion_value.target_roas"
|
|
77
|
+
)
|
|
78
|
+
return value or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _custom_parameters(value: object) -> list[dict]:
|
|
82
|
+
if not isinstance(value, list):
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
{"key": item.get("key", ""), "value": item.get("value", "")}
|
|
86
|
+
for item in value
|
|
87
|
+
if isinstance(item, dict)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _widening(campaign: dict) -> list[str]:
|
|
92
|
+
"""The settings on this campaign that let ads reach beyond what was chosen."""
|
|
93
|
+
found: list[str] = []
|
|
94
|
+
if campaign["search_partners"]:
|
|
95
|
+
found.append("search partners on")
|
|
96
|
+
if campaign["display_network"]:
|
|
97
|
+
found.append("Display network on")
|
|
98
|
+
if campaign["location_option"] in _WIDE_LOCATION_OPTIONS:
|
|
99
|
+
found.append(f"location option {campaign['location_option']}")
|
|
100
|
+
if campaign["keyword_match_type"] == "BROAD":
|
|
101
|
+
found.append("campaign-level broad match on")
|
|
102
|
+
if campaign["enable_ai_max"]:
|
|
103
|
+
found.append("AI Max on")
|
|
104
|
+
for automation, status in campaign["asset_automation"].items():
|
|
105
|
+
if status == "OPTED_IN":
|
|
106
|
+
found.append(f"{automation} opted in")
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_campaign_settings(
|
|
111
|
+
config: AdLoopConfig,
|
|
112
|
+
*,
|
|
113
|
+
customer_id: str = "",
|
|
114
|
+
campaign_id: str = "",
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""Read-only, one flat record per Search campaign. See the module docstring."""
|
|
117
|
+
from adloop.ads.client import get_ads_client, normalize_customer_id
|
|
118
|
+
from adloop.ads.currency import get_currency_code
|
|
119
|
+
|
|
120
|
+
campaign_id = str(campaign_id or "").strip()
|
|
121
|
+
if campaign_id and not campaign_id.isdigit():
|
|
122
|
+
return {"error": "campaign_id must be a numeric ID"}
|
|
123
|
+
|
|
124
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
125
|
+
client = get_ads_client(config)
|
|
126
|
+
result = read_campaign_settings(client, cid, campaign_id=campaign_id)
|
|
127
|
+
result["currency"] = get_currency_code(config, cid)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_campaign_settings(client: object, cid: str, *, campaign_id: str = "") -> dict:
|
|
132
|
+
"""Run both reads and merge them per campaign."""
|
|
133
|
+
from adloop.ads.ai_max import _search, read_ai_max_state
|
|
134
|
+
|
|
135
|
+
if campaign_id and not str(campaign_id).isdigit():
|
|
136
|
+
# The id goes into the query text; anything else must not reach GAQL.
|
|
137
|
+
raise ValueError("campaign_id must be a numeric ID")
|
|
138
|
+
# An explicit ID stays Search-only too: the AI Max and location settings
|
|
139
|
+
# read here don't apply to Performance Max or Display campaigns.
|
|
140
|
+
where = f"campaign.id = {campaign_id} AND {_SEARCH_ONLY}" if campaign_id else _WHERE_ALL
|
|
141
|
+
rows = _search(
|
|
142
|
+
client.get_service("GoogleAdsService"), cid, _SETTINGS_SELECT.format(where=where)
|
|
143
|
+
)
|
|
144
|
+
ai_max = {
|
|
145
|
+
c["campaign_id"]: c
|
|
146
|
+
for c in read_ai_max_state(client, cid, campaign_id=campaign_id)["campaigns"]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
campaigns: list[dict] = []
|
|
150
|
+
for row in rows:
|
|
151
|
+
key = str(row.get("campaign.id", ""))
|
|
152
|
+
if not key or (campaign_id and key != str(campaign_id)):
|
|
153
|
+
continue
|
|
154
|
+
ai = ai_max.get(key, {})
|
|
155
|
+
campaign = {
|
|
156
|
+
"campaign_id": key,
|
|
157
|
+
"campaign_name": row.get("campaign.name"),
|
|
158
|
+
"status": row.get("campaign.status"),
|
|
159
|
+
"advertising_channel_type": row.get("campaign.advertising_channel_type"),
|
|
160
|
+
"bidding_strategy_type": row.get("campaign.bidding_strategy_type"),
|
|
161
|
+
"portfolio_bidding_strategy": row.get("campaign.bidding_strategy") or None,
|
|
162
|
+
"cpc_bid_ceiling": _money(
|
|
163
|
+
row.get("campaign.target_spend.cpc_bid_ceiling_micros")
|
|
164
|
+
),
|
|
165
|
+
"target_cpa": _target_cpa(row),
|
|
166
|
+
"target_roas": _target_roas(row),
|
|
167
|
+
"daily_budget": _money(row.get("campaign_budget.amount_micros")),
|
|
168
|
+
"budget_shared": bool(row.get("campaign_budget.explicitly_shared")),
|
|
169
|
+
"google_search": bool(row.get("campaign.network_settings.target_google_search")),
|
|
170
|
+
"search_partners": bool(
|
|
171
|
+
row.get("campaign.network_settings.target_search_network")
|
|
172
|
+
),
|
|
173
|
+
"display_network": bool(
|
|
174
|
+
row.get("campaign.network_settings.target_content_network")
|
|
175
|
+
),
|
|
176
|
+
"location_option": row.get(
|
|
177
|
+
"campaign.geo_target_type_setting.positive_geo_target_type"
|
|
178
|
+
),
|
|
179
|
+
"negative_location_option": row.get(
|
|
180
|
+
"campaign.geo_target_type_setting.negative_geo_target_type"
|
|
181
|
+
),
|
|
182
|
+
"keyword_match_type": row.get("campaign.keyword_match_type"),
|
|
183
|
+
"tracking_url_template": row.get("campaign.tracking_url_template") or "",
|
|
184
|
+
"final_url_suffix": row.get("campaign.final_url_suffix") or "",
|
|
185
|
+
"url_custom_parameters": _custom_parameters(
|
|
186
|
+
row.get("campaign.url_custom_parameters")
|
|
187
|
+
),
|
|
188
|
+
"enable_ai_max": bool(ai.get("enable_ai_max")),
|
|
189
|
+
"asset_automation": {
|
|
190
|
+
item["asset_automation_type"]: item.get("asset_automation_status", "")
|
|
191
|
+
for item in ai.get("asset_automation_settings", [])
|
|
192
|
+
},
|
|
193
|
+
# Search term matching only acts under AI Max; listing every ad
|
|
194
|
+
# group's default False otherwise would be noise.
|
|
195
|
+
"ad_groups_with_search_term_matching": [
|
|
196
|
+
g["ad_group_name"]
|
|
197
|
+
for g in ai.get("ad_groups", [])
|
|
198
|
+
if g.get("disable_search_term_matching") is False
|
|
199
|
+
] if ai.get("enable_ai_max") else [],
|
|
200
|
+
}
|
|
201
|
+
campaign["widening_settings"] = _widening(campaign)
|
|
202
|
+
campaigns.append(campaign)
|
|
203
|
+
|
|
204
|
+
insights: list[str] = []
|
|
205
|
+
for campaign in campaigns:
|
|
206
|
+
if campaign["widening_settings"]:
|
|
207
|
+
insights.append(
|
|
208
|
+
f"{campaign['campaign_name']} ({campaign['campaign_id']}): "
|
|
209
|
+
+ ", ".join(campaign["widening_settings"])
|
|
210
|
+
+ ". These let the ads reach beyond the keywords and locations "
|
|
211
|
+
"chosen; confirm they are intended."
|
|
212
|
+
)
|
|
213
|
+
if campaigns and not insights:
|
|
214
|
+
insights.append("No setting that widens reach is on in any campaign returned.")
|
|
215
|
+
if not campaigns:
|
|
216
|
+
insights.append("No non-removed Search campaign matched.")
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"campaigns": campaigns,
|
|
220
|
+
"total_campaigns": len(campaigns),
|
|
221
|
+
"insights": insights,
|
|
222
|
+
}
|
|
@@ -843,6 +843,10 @@ def get_recommendations(
|
|
|
843
843
|
pot_conversions = _round_metric(getattr(pot, "conversions", 0))
|
|
844
844
|
|
|
845
845
|
entry = {
|
|
846
|
+
# The ID (last segment of the resource name) is what
|
|
847
|
+
# draft_dismiss_recommendations takes.
|
|
848
|
+
"recommendation.id": rec.resource_name.rsplit("/", 1)[-1],
|
|
849
|
+
"recommendation.resource_name": rec.resource_name,
|
|
846
850
|
"recommendation.type": rec_type,
|
|
847
851
|
"recommendation.campaign": rec.campaign,
|
|
848
852
|
"recommendation.ad_group": rec.ad_group or "",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Dismiss Google Ads recommendations.
|
|
2
|
+
|
|
3
|
+
``get_recommendations`` (in ``ads/read.py``) lists the active ones; this module
|
|
4
|
+
turns a chosen subset down. The v25 call (verified against the SDK):
|
|
5
|
+
|
|
6
|
+
RecommendationService.DismissRecommendation
|
|
7
|
+
DismissRecommendationRequest{customer_id, operations[], partial_failure}
|
|
8
|
+
DismissRecommendationOperation{resource_name}
|
|
9
|
+
|
|
10
|
+
The request has **no** ``validate_only`` field, so a dry run cannot hand the
|
|
11
|
+
exact call to Google the way other Google Ads writes do. Instead the dry run
|
|
12
|
+
re-reads the account and checks that every planned recommendation is still
|
|
13
|
+
active (``preflight``), the same approach the Reddit, Tag Manager and GA4
|
|
14
|
+
writes use. The validate-only client still refuses the call outright, so this
|
|
15
|
+
path can never dismiss anything during a dry run.
|
|
16
|
+
|
|
17
|
+
Dismissing is mild: nothing in the account changes, and Google can show the
|
|
18
|
+
recommendation again later. A single confirmation is enough.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from adloop.config import AdLoopConfig
|
|
28
|
+
|
|
29
|
+
OPERATION = "dismiss_recommendations"
|
|
30
|
+
|
|
31
|
+
# A recommendation ID is the last segment of its resource name. Google does not
|
|
32
|
+
# document the alphabet, so accept what resource names can carry and nothing
|
|
33
|
+
# that could break out of one.
|
|
34
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9_~=+-]+$")
|
|
35
|
+
_RESOURCE_RE = re.compile(r"^customers/(\d+)/recommendations/([A-Za-z0-9_~=+-]+)$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def recommendation_id(resource_name: str) -> str:
|
|
39
|
+
"""``customers/1/recommendations/abc`` -> ``abc``."""
|
|
40
|
+
return str(resource_name or "").rsplit("/", 1)[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean_ids(values: list[str] | None, cid: str) -> tuple[list[str], list[str]]:
|
|
44
|
+
"""Accept bare IDs or full resource names. Returns (errors, deduped IDs)."""
|
|
45
|
+
errors: list[str] = []
|
|
46
|
+
ids: list[str] = []
|
|
47
|
+
for raw in values or []:
|
|
48
|
+
value = str(raw or "").strip()
|
|
49
|
+
if not value:
|
|
50
|
+
continue
|
|
51
|
+
match = _RESOURCE_RE.match(value)
|
|
52
|
+
if match:
|
|
53
|
+
if match.group(1) != cid:
|
|
54
|
+
errors.append(
|
|
55
|
+
f"{value} belongs to account {match.group(1)}, not {cid}"
|
|
56
|
+
)
|
|
57
|
+
continue
|
|
58
|
+
value = match.group(2)
|
|
59
|
+
elif not _ID_RE.match(value):
|
|
60
|
+
errors.append(f"'{value}' is not a recommendation ID or resource name")
|
|
61
|
+
continue
|
|
62
|
+
if value not in ids:
|
|
63
|
+
ids.append(value)
|
|
64
|
+
return errors, ids
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _active_by_id(config: AdLoopConfig, customer_id: str) -> dict[str, dict]:
|
|
68
|
+
"""Every active (not dismissed) recommendation, keyed by ID."""
|
|
69
|
+
from adloop.ads.read import get_recommendations
|
|
70
|
+
|
|
71
|
+
rows = get_recommendations(config, customer_id=customer_id).get(
|
|
72
|
+
"recommendations", []
|
|
73
|
+
)
|
|
74
|
+
return {
|
|
75
|
+
recommendation_id(row.get("recommendation.resource_name", "")): row
|
|
76
|
+
for row in rows
|
|
77
|
+
if row.get("recommendation.resource_name")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def draft_dismiss_recommendations(
|
|
82
|
+
config: AdLoopConfig,
|
|
83
|
+
*,
|
|
84
|
+
customer_id: str = "",
|
|
85
|
+
recommendation_ids: list[str] | None = None,
|
|
86
|
+
) -> dict:
|
|
87
|
+
"""Draft dismissing recommendations — returns PREVIEW.
|
|
88
|
+
|
|
89
|
+
Reads the active recommendations first: an ID that is unknown or already
|
|
90
|
+
dismissed is refused by name, so the preview only ever lists what the
|
|
91
|
+
apply will turn down. Each entry carries its type, campaign and Google's
|
|
92
|
+
estimated improvement, the same numbers ``get_recommendations`` shows.
|
|
93
|
+
|
|
94
|
+
Call ``confirm_and_apply`` with the returned plan_id to execute.
|
|
95
|
+
"""
|
|
96
|
+
from adloop.ads.client import normalize_customer_id
|
|
97
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
98
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
check_blocked_operation(OPERATION, config.safety)
|
|
102
|
+
except SafetyViolation as e:
|
|
103
|
+
return {"error": str(e)}
|
|
104
|
+
|
|
105
|
+
cid = normalize_customer_id(customer_id or config.ads.customer_id)
|
|
106
|
+
errors, ids = _clean_ids(recommendation_ids, cid)
|
|
107
|
+
if not ids and not errors:
|
|
108
|
+
errors.append("At least one recommendation_id is required")
|
|
109
|
+
if errors:
|
|
110
|
+
return {"error": "Validation failed", "details": errors}
|
|
111
|
+
|
|
112
|
+
active = _active_by_id(config, customer_id)
|
|
113
|
+
missing = [rec_id for rec_id in ids if rec_id not in active]
|
|
114
|
+
if missing:
|
|
115
|
+
return {
|
|
116
|
+
"error": "Validation failed",
|
|
117
|
+
"details": [
|
|
118
|
+
"Not an active recommendation in this account (unknown, or "
|
|
119
|
+
"already dismissed): " + ", ".join(missing)
|
|
120
|
+
+ ". get_recommendations lists the active ones with their IDs."
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
recommendations = [
|
|
125
|
+
{
|
|
126
|
+
"recommendation_id": rec_id,
|
|
127
|
+
"resource_name": active[rec_id]["recommendation.resource_name"],
|
|
128
|
+
"type": active[rec_id].get("recommendation.type", ""),
|
|
129
|
+
"campaign": active[rec_id].get("recommendation.campaign", ""),
|
|
130
|
+
"ad_group": active[rec_id].get("recommendation.ad_group", ""),
|
|
131
|
+
"estimated_improvement": active[rec_id].get("estimated_improvement", {}),
|
|
132
|
+
}
|
|
133
|
+
for rec_id in ids
|
|
134
|
+
]
|
|
135
|
+
plan = ChangePlan(
|
|
136
|
+
operation=OPERATION,
|
|
137
|
+
entity_type="recommendation",
|
|
138
|
+
entity_id=",".join(ids),
|
|
139
|
+
customer_id=customer_id,
|
|
140
|
+
changes={
|
|
141
|
+
"recommendations": recommendations,
|
|
142
|
+
"count": len(recommendations),
|
|
143
|
+
"note": (
|
|
144
|
+
"Dismissing changes nothing in the account. Google can show a "
|
|
145
|
+
"dismissed recommendation again later."
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
store_plan(plan)
|
|
150
|
+
return plan.to_preview()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def preflight(config: AdLoopConfig, plan: object) -> dict:
|
|
154
|
+
"""Dry run: every planned recommendation must still be active.
|
|
155
|
+
|
|
156
|
+
DismissRecommendation has no validate-only mode, so this re-read is the
|
|
157
|
+
whole check. Raises when any recommendation has gone (dismissed in the UI,
|
|
158
|
+
applied, or expired), so the dry run fails instead of reporting success.
|
|
159
|
+
"""
|
|
160
|
+
planned = [r["recommendation_id"] for r in plan.changes["recommendations"]]
|
|
161
|
+
active = _active_by_id(config, plan.customer_id)
|
|
162
|
+
gone = [rec_id for rec_id in planned if rec_id not in active]
|
|
163
|
+
if gone:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"No longer active (dismissed, applied or expired since the "
|
|
166
|
+
"preview): " + ", ".join(gone) + ". Draft again."
|
|
167
|
+
)
|
|
168
|
+
return {"recommendations_checked": len(planned), "still_active": len(planned)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _apply_dismiss_recommendations(client: object, cid: str, changes: dict) -> dict:
|
|
172
|
+
"""Dismiss the planned recommendations; per-recommendation errors are reported."""
|
|
173
|
+
from adloop.ads.write import _parse_partial_failure_per_op
|
|
174
|
+
|
|
175
|
+
names = [r["resource_name"] for r in changes["recommendations"]]
|
|
176
|
+
service = client.get_service("RecommendationService")
|
|
177
|
+
request = client.get_type("DismissRecommendationRequest")
|
|
178
|
+
request.customer_id = cid
|
|
179
|
+
request.partial_failure = True
|
|
180
|
+
operation_type = type(request).DismissRecommendationOperation
|
|
181
|
+
for name in names:
|
|
182
|
+
request.operations.append(operation_type(resource_name=name))
|
|
183
|
+
|
|
184
|
+
response = service.dismiss_recommendation(request=request)
|
|
185
|
+
|
|
186
|
+
pf_error = getattr(response, "partial_failure_error", None)
|
|
187
|
+
per_op_errors = _parse_partial_failure_per_op(client, pf_error)
|
|
188
|
+
dismissed: list[str] = []
|
|
189
|
+
failed: list[dict] = []
|
|
190
|
+
for index, result in enumerate(response.results):
|
|
191
|
+
if result.resource_name:
|
|
192
|
+
dismissed.append(result.resource_name)
|
|
193
|
+
else:
|
|
194
|
+
failed.append(
|
|
195
|
+
{
|
|
196
|
+
"recommendation_id": recommendation_id(names[index]),
|
|
197
|
+
"error": per_op_errors.get(
|
|
198
|
+
index, "Unknown error (see partial_failure_message)"
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
out: dict = {"dismissed": dismissed, "dismissed_count": len(dismissed)}
|
|
204
|
+
if failed:
|
|
205
|
+
out["partial_failure"] = True
|
|
206
|
+
out["failed"] = failed
|
|
207
|
+
message = getattr(pf_error, "message", "") if pf_error is not None else ""
|
|
208
|
+
if message:
|
|
209
|
+
out["partial_failure_message"] = message
|
|
210
|
+
return out
|
|
@@ -2717,6 +2717,9 @@ def confirm_and_apply(
|
|
|
2717
2717
|
is_reddit = plan.operation.startswith("reddit_")
|
|
2718
2718
|
is_gtm = plan.operation.startswith("gtm_")
|
|
2719
2719
|
is_ga4 = plan.operation in GA4_OPERATIONS
|
|
2720
|
+
# Google Ads calls without a validate_only field: the dry run re-reads the
|
|
2721
|
+
# target instead, like the platforms above.
|
|
2722
|
+
is_ads_reread = plan.operation == "dismiss_recommendations"
|
|
2720
2723
|
platform_label = (
|
|
2721
2724
|
"Reddit Ads" if is_reddit
|
|
2722
2725
|
else "Google Tag Manager" if is_gtm
|
|
@@ -2749,6 +2752,12 @@ def confirm_and_apply(
|
|
|
2749
2752
|
from adloop.ga4.write import preflight as ga4_preflight
|
|
2750
2753
|
|
|
2751
2754
|
preflight_checks = ga4_preflight(config, plan)
|
|
2755
|
+
elif is_ads_reread:
|
|
2756
|
+
# DismissRecommendation has no validate_only field, and the
|
|
2757
|
+
# validate-only client refuses it; re-read the targets instead.
|
|
2758
|
+
from adloop.ads.recommendations import preflight as rec_preflight
|
|
2759
|
+
|
|
2760
|
+
preflight_checks = rec_preflight(config, plan)
|
|
2752
2761
|
else:
|
|
2753
2762
|
# Google Ads checks the exact mutates with validate_only=True
|
|
2754
2763
|
# and executes nothing.
|
|
@@ -2784,7 +2793,7 @@ def confirm_and_apply(
|
|
|
2784
2793
|
)
|
|
2785
2794
|
checked_against = (
|
|
2786
2795
|
f"re-checked the target against {platform_label}"
|
|
2787
|
-
if is_reddit or is_gtm or is_ga4
|
|
2796
|
+
if is_reddit or is_gtm or is_ga4 or is_ads_reread
|
|
2788
2797
|
else "sent the change to Google Ads in validate-only mode"
|
|
2789
2798
|
)
|
|
2790
2799
|
return {
|
|
@@ -2835,7 +2844,9 @@ def confirm_and_apply(
|
|
|
2835
2844
|
if preflight_checks is not None:
|
|
2836
2845
|
response["checks"] = preflight_checks
|
|
2837
2846
|
response["note"] = (
|
|
2838
|
-
f"{platform_label} has no validate-only mode
|
|
2847
|
+
f"{platform_label} has no validate-only mode"
|
|
2848
|
+
+ (" for dismissing recommendations" if is_ads_reread else "")
|
|
2849
|
+
+ ": the dry run "
|
|
2839
2850
|
"re-read the target and re-checked the safety gates; nothing "
|
|
2840
2851
|
"was changed live."
|
|
2841
2852
|
+ (
|
|
@@ -4089,6 +4100,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
|
|
|
4089
4100
|
from adloop.ads.pmax import apply_asset_group_assets as _apply_asset_group_assets
|
|
4090
4101
|
from adloop.ads.pmax import apply_asset_group_signals as _apply_asset_group_signals
|
|
4091
4102
|
from adloop.ads.pmax import apply_pmax_product_filter as _apply_pmax_product_filter
|
|
4103
|
+
from adloop.ads.recommendations import _apply_dismiss_recommendations
|
|
4092
4104
|
# Custom conversion goals live in their own module for the same reason.
|
|
4093
4105
|
from adloop.ads.custom_conversion_goals import (
|
|
4094
4106
|
_apply_assign_custom_conversion_goal,
|
|
@@ -4123,6 +4135,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
|
|
|
4123
4135
|
"attach_brand_list_to_campaigns": _apply_attach_brand_list_to_campaigns,
|
|
4124
4136
|
"detach_brand_list_from_campaigns": _apply_detach_brand_list_from_campaigns,
|
|
4125
4137
|
"update_ai_max_settings": _apply_ai_max_settings,
|
|
4138
|
+
"dismiss_recommendations": _apply_dismiss_recommendations,
|
|
4126
4139
|
"create_custom_conversion_goal": _apply_create_custom_conversion_goal,
|
|
4127
4140
|
"update_custom_conversion_goal": _apply_update_custom_conversion_goal,
|
|
4128
4141
|
"assign_custom_conversion_goal": _apply_assign_custom_conversion_goal,
|
|
@@ -995,7 +995,7 @@ def _wizard_finish(
|
|
|
995
995
|
|
|
996
996
|
def _maybe_offer_global_rules_install() -> None:
|
|
997
997
|
"""If Claude is detected, offer to install rules globally during init."""
|
|
998
|
-
from adloop.rules_install import detect_clients, install_rules
|
|
998
|
+
from adloop.rules_install import detect_clients, install_rules, rules_token_estimate
|
|
999
999
|
|
|
1000
1000
|
clients = detect_clients()
|
|
1001
1001
|
if not clients:
|
|
@@ -1017,16 +1017,19 @@ def _maybe_offer_global_rules_install() -> None:
|
|
|
1017
1017
|
_print(" Skipping. Run `adloop install-rules` later if you change your mind.")
|
|
1018
1018
|
return
|
|
1019
1019
|
|
|
1020
|
+
# Lazy is the default: inline puts the full rules into every Claude Code
|
|
1021
|
+
# session in every repository, AdLoop or not (#114).
|
|
1022
|
+
tokens = rules_token_estimate() // 1000
|
|
1020
1023
|
_print()
|
|
1021
1024
|
_print(" Install mode:")
|
|
1022
|
-
_print(" 1.
|
|
1023
|
-
_print("
|
|
1024
|
-
_print("
|
|
1025
|
-
_print("
|
|
1026
|
-
_print("
|
|
1025
|
+
_print(" 1. lazy (default) — a short pointer in ~/.claude/CLAUDE.md; Claude")
|
|
1026
|
+
_print(" reads the full rules (~/.adloop/rules/adloop.md) only when")
|
|
1027
|
+
_print(" AdLoop tools are in use. Almost free in sessions without AdLoop.")
|
|
1028
|
+
_print(" 2. inline — the full rules in ~/.claude/CLAUDE.md, loaded into")
|
|
1029
|
+
_print(f" every Claude Code session (about {tokens}K tokens). Most reliable.")
|
|
1027
1030
|
_print()
|
|
1028
1031
|
raw = input(" Choose [1/2, default 1]: ").strip()
|
|
1029
|
-
mode = "
|
|
1032
|
+
mode = "inline" if raw == "2" else "lazy"
|
|
1030
1033
|
|
|
1031
1034
|
_print()
|
|
1032
1035
|
results = install_rules(mode=mode)
|
|
@@ -70,6 +70,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
70
70
|
| `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) |
|
|
71
71
|
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) |
|
|
72
72
|
| `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) |
|
|
73
|
+
| `get_campaign_settings` | Every setting that decides how far a Search campaign reaches, one flat record per campaign: networks, location option, campaign-level broad match, bidding and budget, tracking template / URL suffix / custom parameters, and the AI Max state. `widening_settings` and `insights[]` name anything that lets ads reach beyond the chosen keywords and locations. The one call for a recurring "did Google switch something on?" audit | `campaign_id` (optional) |
|
|
73
74
|
| `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) |
|
|
74
75
|
| `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) |
|
|
75
76
|
|
|
@@ -79,7 +80,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
|
|
|
79
80
|
- `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs, plus `ad_group_ad.policy_summary.approval_status`, `review_status` and `policy_topics` (topic names only) per ad. Ads that are `DISAPPROVED` (cannot serve) or `APPROVED_LIMITED` (serving restricted) are listed in `policy_issues` with their spend and flagged in `insights[]`; compact mode adds `by_approval_status` counts.
|
|
80
81
|
- `get_campaign_performance` returns `metrics.search_impression_share`, `metrics.search_budget_lost_impression_share` and `metrics.search_rank_lost_impression_share` as fractions (0.25 = 25%) with matching `*_pct` percentages. Google caps the reported values: 0.0999 means below 10% share, 0.9001 means above 90% lost. They are `null` for campaigns that do not serve on Search (Display, Video, PMax, ...) or had no impressions, which means "does not apply", not zero. `budget_limited_converters` and `insights[]` flag converting campaigns losing 20% or more of impressions to budget: those can absorb more budget, rank-lost share instead points at bids, ad relevance and quality.
|
|
81
82
|
- `get_change_history` returns changes newest first with counts `by_resource_type`, `by_client`, `by_user` and `by_day`. Google Ads keeps 30 days of change history, so an older start date is moved forward (see `notes`). `insights[]` flag auto-applied recommendations and budget or bidding changes. `truncated: true` means the limit was hit; narrow the range or filters to see older changes.
|
|
82
|
-
- `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag budget-increase recommendations, whose projected gain comes from spending more.
|
|
83
|
+
- `get_recommendations` returns each recommendation's `recommendation.id` (what `draft_dismiss_recommendations` takes) and `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag budget-increase recommendations, whose projected gain comes from spending more.
|
|
83
84
|
- 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.
|
|
84
85
|
- `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.
|
|
85
86
|
- `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.
|
|
@@ -317,6 +318,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
|
|
|
317
318
|
| `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` |
|
|
318
319
|
| `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 |
|
|
319
320
|
| `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 |
|
|
321
|
+
| `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) |
|
|
320
322
|
| `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) |
|
|
321
323
|
| `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` |
|
|
322
324
|
| `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` |
|
|
@@ -657,6 +659,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
|
|
|
657
659
|
4. Use `estimated_improvement` to prioritize: recommendations with >1 estimated additional conversion are worth investigating
|
|
658
660
|
5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting
|
|
659
661
|
6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue
|
|
662
|
+
7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf
|
|
660
663
|
|
|
661
664
|
### When user asks about audience performance or targeting
|
|
662
665
|
|
|
@@ -20,12 +20,14 @@ on upgrade. Anything outside the sentinel pair is preserved verbatim.
|
|
|
20
20
|
Two install modes:
|
|
21
21
|
|
|
22
22
|
- **inline** (default): the full rules text is pasted between the sentinels
|
|
23
|
-
in ``~/.claude/CLAUDE.md``. Reliable but
|
|
24
|
-
|
|
23
|
+
in ``~/.claude/CLAUDE.md``. Reliable, but loaded into every Claude Code
|
|
24
|
+
session in every repository (:func:`rules_token_estimate`, about 30K tokens).
|
|
25
25
|
- **lazy**: a small directive is written between the sentinels pointing at
|
|
26
|
-
``~/.
|
|
26
|
+
``~/.adloop/rules/adloop.md`` (which contains the full rules). The LLM is
|
|
27
27
|
instructed to read the rules file only when AdLoop tools are in scope.
|
|
28
|
-
|
|
28
|
+
The file deliberately lives outside ``~/.claude/``: Claude Code loads every
|
|
29
|
+
file in ``~/.claude/rules/`` into every session, which is where lazy mode
|
|
30
|
+
used to write it (#114). Cheaper baseline cost, less reliable.
|
|
29
31
|
|
|
30
32
|
Slash commands are installed namespaced (``adloop-*``) so they don't collide
|
|
31
33
|
with user-defined commands of the same name.
|
|
@@ -61,11 +63,18 @@ _SENTINEL_BLOCK_RE = re.compile(
|
|
|
61
63
|
|
|
62
64
|
InstallMode = Literal["inline", "lazy"]
|
|
63
65
|
|
|
66
|
+
# Where lazy mode keeps the full rules, relative to the home directory. Not
|
|
67
|
+
# under ~/.claude/: Claude Code auto-loads everything in ~/.claude/rules/.
|
|
68
|
+
LAZY_RULES_RELATIVE = Path(".adloop") / "rules" / "adloop.md"
|
|
69
|
+
# Where lazy mode wrote them before #114. Cleaned up when it is ours.
|
|
70
|
+
_LEGACY_LAZY_RULES_RELATIVE = Path(".claude") / "rules" / "adloop.md"
|
|
71
|
+
_RULES_HEADING = "# AdLoop — AI Orchestration Rules"
|
|
72
|
+
|
|
64
73
|
# The directive used in lazy mode. Kept short to minimise baseline token cost.
|
|
65
74
|
_LAZY_DIRECTIVE = """\
|
|
66
75
|
**AdLoop orchestration**: if MCP tools like `get_campaign_performance`,
|
|
67
76
|
`run_gaql`, `draft_responsive_search_ad`, or other AdLoop tools are available
|
|
68
|
-
in this session, read `~/.
|
|
77
|
+
in this session, read `~/.adloop/rules/adloop.md` for the full safety rules,
|
|
69
78
|
orchestration patterns, and GAQL reference before using them. Skip this read
|
|
70
79
|
if AdLoop is not active in the current session.
|
|
71
80
|
"""
|
|
@@ -182,7 +191,7 @@ def _read_bundled_rules() -> str:
|
|
|
182
191
|
"""Load the bundled rules markdown shipped with the package.
|
|
183
192
|
|
|
184
193
|
Returns the file as-is (with its YAML frontmatter intact) since lazy mode
|
|
185
|
-
writes it to ``~/.
|
|
194
|
+
writes it to ``~/.adloop/rules/adloop.md`` where the frontmatter is
|
|
186
195
|
meaningful. For inline mode, use :func:`_read_bundled_rules_body`.
|
|
187
196
|
"""
|
|
188
197
|
ref = importlib.resources.files("adloop.rules").joinpath("adloop.md")
|
|
@@ -203,6 +212,38 @@ def _read_bundled_rules_body() -> str:
|
|
|
203
212
|
return text[end + 3:].lstrip("\n")
|
|
204
213
|
|
|
205
214
|
|
|
215
|
+
def rules_token_estimate() -> int:
|
|
216
|
+
"""Rough token cost of the inline rules, measured from the bundled file.
|
|
217
|
+
|
|
218
|
+
About four bytes per token, rounded to the nearest thousand, so the
|
|
219
|
+
wizard and README never quote a stale hard-coded figure again.
|
|
220
|
+
"""
|
|
221
|
+
size = len(_read_bundled_rules_body().encode("utf-8"))
|
|
222
|
+
return max(1000, round(size / 4 / 1000) * 1000)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _home_of(rules_target: Path) -> Path:
|
|
226
|
+
"""``~/.claude/CLAUDE.md`` -> ``~``."""
|
|
227
|
+
return rules_target.parent.parent
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _remove_if_ours(path: Path) -> None:
|
|
231
|
+
"""Delete a rules file AdLoop wrote; never a file the user wrote."""
|
|
232
|
+
if not path.is_file():
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
text = path.read_text()
|
|
236
|
+
except (OSError, UnicodeDecodeError):
|
|
237
|
+
return
|
|
238
|
+
if _RULES_HEADING not in text:
|
|
239
|
+
return
|
|
240
|
+
path.unlink()
|
|
241
|
+
try:
|
|
242
|
+
path.parent.rmdir() # only if empty
|
|
243
|
+
except OSError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
|
|
206
247
|
def _list_bundled_commands() -> list[tuple[str, str]]:
|
|
207
248
|
"""Return ``[(filename, content), ...]`` for every bundled slash command."""
|
|
208
249
|
commands: list[tuple[str, str]] = []
|
|
@@ -335,16 +376,10 @@ def uninstall_rules(
|
|
|
335
376
|
client.rules_target.write_text(cleaned)
|
|
336
377
|
result.action = "uninstalled"
|
|
337
378
|
|
|
338
|
-
# Remove the lazy-mode rules file
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
)
|
|
342
|
-
if lazy_rules_file.exists():
|
|
343
|
-
lazy_rules_file.unlink()
|
|
344
|
-
try:
|
|
345
|
-
lazy_rules_file.parent.rmdir() # only if empty
|
|
346
|
-
except OSError:
|
|
347
|
-
pass
|
|
379
|
+
# Remove the lazy-mode rules file, at its current and its old place.
|
|
380
|
+
home = _home_of(client.rules_target)
|
|
381
|
+
_remove_if_ours(home / LAZY_RULES_RELATIVE)
|
|
382
|
+
_remove_if_ours(home / _LEGACY_LAZY_RULES_RELATIVE)
|
|
348
383
|
|
|
349
384
|
# Remove namespaced slash commands
|
|
350
385
|
if remove_commands and client.commands_dir and client.commands_dir.exists():
|
|
@@ -397,11 +432,18 @@ def _apply_to_clients(
|
|
|
397
432
|
else:
|
|
398
433
|
resolved_mode = "inline"
|
|
399
434
|
|
|
400
|
-
# In lazy mode
|
|
435
|
+
# In lazy mode the full rules go to a file Claude Code does not load
|
|
436
|
+
# on its own. A file from an older lazy install under ~/.claude/rules/
|
|
437
|
+
# would be loaded into every session, so it goes either way; inline
|
|
438
|
+
# mode needs no lazy file at all.
|
|
439
|
+
home = _home_of(rules_target)
|
|
440
|
+
_remove_if_ours(home / _LEGACY_LAZY_RULES_RELATIVE)
|
|
441
|
+
lazy_rules_file = home / LAZY_RULES_RELATIVE
|
|
401
442
|
if resolved_mode == "lazy":
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
443
|
+
lazy_rules_file.parent.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
lazy_rules_file.write_text(_read_bundled_rules())
|
|
445
|
+
else:
|
|
446
|
+
_remove_if_ours(lazy_rules_file)
|
|
405
447
|
|
|
406
448
|
new_block = _build_managed_block(resolved_mode, rules_target)
|
|
407
449
|
merged = _replace_or_append_block(existing, new_block)
|
|
@@ -431,8 +473,10 @@ def _existing_block_is_lazy(existing: str) -> bool:
|
|
|
431
473
|
if not match:
|
|
432
474
|
return False
|
|
433
475
|
block = match.group(0)
|
|
434
|
-
# Lazy blocks are short and reference the rules file path
|
|
435
|
-
|
|
476
|
+
# Lazy blocks are short and reference the rules file path (the current one,
|
|
477
|
+
# or the ~/.claude/rules/ one older versions wrote).
|
|
478
|
+
mentions_file = "~/.adloop/rules/adloop.md" in block or "~/.claude/rules/adloop.md" in block
|
|
479
|
+
return mentions_file and len(block) < 2000
|
|
436
480
|
|
|
437
481
|
|
|
438
482
|
# ---------------------------------------------------------------------------
|
|
@@ -1546,10 +1546,12 @@ def get_recommendations(
|
|
|
1546
1546
|
) -> dict:
|
|
1547
1547
|
"""Retrieve Google's auto-generated recommendations with estimated impact.
|
|
1548
1548
|
|
|
1549
|
-
Returns each recommendation's type, associated campaign/ad group,
|
|
1550
|
-
(base) and projected (potential) metrics, and the estimated
|
|
1551
|
-
Includes insights that flag budget-increase recommendations
|
|
1552
|
-
gain comes from spending more) and highlight high-impact
|
|
1549
|
+
Returns each recommendation's ID, type, associated campaign/ad group,
|
|
1550
|
+
current (base) and projected (potential) metrics, and the estimated
|
|
1551
|
+
improvement. Includes insights that flag budget-increase recommendations
|
|
1552
|
+
(their projected gain comes from spending more) and highlight high-impact
|
|
1553
|
+
suggestions. The `recommendation.id` is what draft_dismiss_recommendations
|
|
1554
|
+
takes.
|
|
1553
1555
|
|
|
1554
1556
|
Args:
|
|
1555
1557
|
customer_id: Google Ads customer ID, digits with or without dashes
|
|
@@ -1570,6 +1572,44 @@ def get_recommendations(
|
|
|
1570
1572
|
)
|
|
1571
1573
|
|
|
1572
1574
|
|
|
1575
|
+
@_tool(title="Draft dismissing recommendations", annotations=_WRITE, tags={"ads"})
|
|
1576
|
+
@_safe
|
|
1577
|
+
def draft_dismiss_recommendations(
|
|
1578
|
+
recommendation_ids: _StrList,
|
|
1579
|
+
customer_id: str = "",
|
|
1580
|
+
) -> dict:
|
|
1581
|
+
"""Dismiss Google Ads recommendations — returns a PREVIEW.
|
|
1582
|
+
|
|
1583
|
+
Turns down recommendations you will not take (e.g. "add broad match" on a
|
|
1584
|
+
deliberately narrow account) so they stop reappearing in
|
|
1585
|
+
get_recommendations. Nothing in the account changes, and Google can show a
|
|
1586
|
+
dismissed recommendation again later, so a single confirmation is enough.
|
|
1587
|
+
|
|
1588
|
+
Each ID must be an active recommendation: unknown or already dismissed
|
|
1589
|
+
IDs are refused by name. The preview lists each one's type, campaign and
|
|
1590
|
+
estimated improvement, so the user sees what they are turning down.
|
|
1591
|
+
|
|
1592
|
+
DismissRecommendation has no validate-only mode: the dry run re-reads the
|
|
1593
|
+
account and checks every recommendation is still active instead of
|
|
1594
|
+
sending the call to Google. The returned plan_id is applied with
|
|
1595
|
+
confirm_and_apply.
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
recommendation_ids: `recommendation.id` values from
|
|
1599
|
+
get_recommendations, or full resource names
|
|
1600
|
+
(customers/{customer_id}/recommendations/{id}). Duplicates are
|
|
1601
|
+
collapsed.
|
|
1602
|
+
customer_id: Ads account ID. Defaults to the configured account.
|
|
1603
|
+
"""
|
|
1604
|
+
from adloop.ads.recommendations import draft_dismiss_recommendations as _impl
|
|
1605
|
+
|
|
1606
|
+
return _impl(
|
|
1607
|
+
current_config(),
|
|
1608
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
1609
|
+
recommendation_ids=recommendation_ids,
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
|
|
1573
1613
|
@_tool(title="Performance Max performance", annotations=_READONLY, tags={"ads"})
|
|
1574
1614
|
@_safe
|
|
1575
1615
|
def get_pmax_performance(
|
|
@@ -3980,6 +4020,44 @@ def get_ai_max_settings(
|
|
|
3980
4020
|
campaign_id=campaign_id,
|
|
3981
4021
|
)
|
|
3982
4022
|
|
|
4023
|
+
|
|
4024
|
+
@_tool(title="Campaign settings", annotations=_READONLY, tags={"ads"})
|
|
4025
|
+
@_safe
|
|
4026
|
+
def get_campaign_settings(
|
|
4027
|
+
campaign_id: str = "",
|
|
4028
|
+
customer_id: str = "",
|
|
4029
|
+
) -> dict:
|
|
4030
|
+
"""Read every setting that decides how far a Search campaign reaches, in one call.
|
|
4031
|
+
|
|
4032
|
+
Returns one flat record per campaign: status, bidding strategy and its
|
|
4033
|
+
target or CPC ceiling, daily budget and whether it is shared, the Google
|
|
4034
|
+
Search / search partner / Display network flags, the location option
|
|
4035
|
+
(positive_geo_target_type) and its negative counterpart, campaign-level
|
|
4036
|
+
broad match (keyword_match_type), the tracking template, final URL suffix
|
|
4037
|
+
and custom parameters, and the AI Max state get_ai_max_settings reports
|
|
4038
|
+
(enable_ai_max, asset automation per type). Money is in the account
|
|
4039
|
+
currency (`currency`).
|
|
4040
|
+
|
|
4041
|
+
Each campaign lists its `widening_settings`: search partners or Display on,
|
|
4042
|
+
a presence-or-interest location option, broad match on, AI Max on, an
|
|
4043
|
+
automation opted in. `insights[]` names the campaigns that have any. Made
|
|
4044
|
+
for recurring audits: the same call every week diffs cleanly.
|
|
4045
|
+
|
|
4046
|
+
Args:
|
|
4047
|
+
campaign_id: Numeric campaign ID of a Search campaign. When omitted,
|
|
4048
|
+
every non-removed Search campaign in the account is listed.
|
|
4049
|
+
customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
|
|
4050
|
+
the configured account.
|
|
4051
|
+
"""
|
|
4052
|
+
from adloop.ads.campaign_settings import get_campaign_settings as _impl
|
|
4053
|
+
|
|
4054
|
+
return _impl(
|
|
4055
|
+
current_config(),
|
|
4056
|
+
customer_id=customer_id or current_config().ads.customer_id,
|
|
4057
|
+
campaign_id=campaign_id,
|
|
4058
|
+
)
|
|
4059
|
+
|
|
4060
|
+
|
|
3983
4061
|
@_tool(title="Draft AI Max settings", annotations=_WRITE, tags={"ads"})
|
|
3984
4062
|
@_safe
|
|
3985
4063
|
def draft_ai_max_settings(
|
|
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
|