From 7efceb7d9de5fd9110742fa76531794eaff17810 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 4 Oct 2026 00:06:03 +0200 Subject: [PATCH] feat(ads): add Google Ads recommendations and optimization score Four calls on ads.google: recommendations, optimization_score, and apply_recommendations and dismiss_recommendations, which need publish. --- src/fopost/__init__.py | 8 ++++ src/fopost/models.py | 32 +++++++++++++++ src/fopost/resources/google_ads.py | 66 ++++++++++++++++++++++++++++++ tests/test_google_ads.py | 45 ++++++++++++++++++++ 4 files changed, 151 insertions(+) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 30362aa..43a29be 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -97,6 +97,10 @@ GoogleKeyword, GoogleKeywordIdea, GoogleLocalServicesLead, + GoogleOptimizationScore, + GoogleOptimizationScoreCampaign, + GoogleRecommendation, + GoogleRecommendationImpact, GoogleSearchTerm, GoogleSharedSet, InboxAccount, @@ -361,6 +365,10 @@ "GoogleKeyword", "GoogleKeywordIdea", "GoogleLocalServicesLead", + "GoogleOptimizationScore", + "GoogleOptimizationScoreCampaign", + "GoogleRecommendation", + "GoogleRecommendationImpact", "GoogleSearchTerm", "GoogleSharedSet", "InsightsMetrics", diff --git a/src/fopost/models.py b/src/fopost/models.py index dab2af9..0720fe2 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1997,6 +1997,38 @@ class GoogleAssetGroup(FopostModel): final_urls: list[str] = [] +class GoogleRecommendationImpact(FopostModel): + base_clicks: float | None = None + potential_clicks: float | None = None + base_cost_minor: int | None = None + potential_cost_minor: int | None = None + base_conversions: float | None = None + potential_conversions: float | None = None + + +class GoogleRecommendation(FopostModel): + """``id`` is the Google resource name, which is what apply and dismiss take.""" + + id: str + type: str + campaign_id: str | None = None + ad_group_id: str | None = None + dismissed: bool = False + impact: GoogleRecommendationImpact | None = None + + +class GoogleOptimizationScoreCampaign(FopostModel): + id: str + name: str + score: float | None = None + + +class GoogleOptimizationScore(FopostModel): + score: float | None = None + weight: float | None = None + campaigns: list[GoogleOptimizationScoreCampaign] = [] + + class GoogleLocalServicesLead(FopostModel): id: str category: str | None = None diff --git a/src/fopost/resources/google_ads.py b/src/fopost/resources/google_ads.py index 31b98a7..0e873fb 100644 --- a/src/fopost/resources/google_ads.py +++ b/src/fopost/resources/google_ads.py @@ -26,6 +26,8 @@ GoogleKeyword, GoogleKeywordIdea, GoogleLocalServicesLead, + GoogleOptimizationScore, + GoogleRecommendation, GoogleSearchTerm, GoogleSharedSet, ) @@ -491,6 +493,70 @@ def upload_conversion_adjustments( result = unwrap(self._http.post("/ads/google/conversions/adjustments", body)) return int(result.get("uploaded", 0)) if isinstance(result, dict) else 0 + # ── Recommendations ── + + def recommendations( + self, + *, + connection_id: str, + customer_id: str, + types: Sequence[str] | None = None, + workspace_id: str | None = None, + ) -> builtins.list[GoogleRecommendation]: + """Google's own read on what the account should change next.""" + params = _query(workspace_id, connection_id, customer_id) + if types: + params["types"] = ",".join(types) + return parse_list( + GoogleRecommendation, + unwrap(self._http.get("/ads/google/recommendations", params)), + ) + + def optimization_score( + self, + *, + connection_id: str, + customer_id: str, + workspace_id: str | None = None, + ) -> GoogleOptimizationScore: + """The account's score and weight, and the score of each live campaign.""" + return GoogleOptimizationScore.model_validate( + unwrap( + self._http.get( + "/ads/google/optimization-score", + _query(workspace_id, connection_id, customer_id), + ) + ) + ) + + def apply_recommendations( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + ids: Sequence[str], + ) -> int: + """Changes what the live account serves or bids. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["ids"] = list(ids) + result = unwrap(self._http.post("/ads/google/recommendations/apply", body)) + return int(result.get("applied", 0)) if isinstance(result, dict) else 0 + + def dismiss_recommendations( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + ids: Sequence[str], + ) -> int: + """Hides each recommendation. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["ids"] = list(ids) + result = unwrap(self._http.post("/ads/google/recommendations/dismiss", body)) + return int(result.get("dismissed", 0)) if isinstance(result, dict) else 0 + # ── GAQL ── def query( diff --git a/tests/test_google_ads.py b/tests/test_google_ads.py index 95ea564..061604e 100644 --- a/tests/test_google_ads.py +++ b/tests/test_google_ads.py @@ -101,3 +101,48 @@ def test_authorize_google_has_its_own_route(client: Fopost) -> None: url = client.ads.authorize_google(workspace_id="ws_1") assert url == "https://accounts.google.com/o/x" assert route.called + + +@respx.mock +def test_recommendations_joins_the_types_filter(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/ads/google/recommendations").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "customers/1234567890/recommendations/ABC~1", + "type": "KEYWORD", + "campaignId": "1234567890~campaign~55", + "dismissed": False, + "impact": {"baseClicks": 10, "potentialClicks": 25}, + } + ] + }, + ) + ) + rows = client.ads.google.recommendations( + connection_id="conn_1", + customer_id="1234567890", + types=["KEYWORD", "TARGET_CPA_OPT_IN"], + ) + assert rows[0].type == "KEYWORD" + assert rows[0].impact is not None and rows[0].impact.potential_clicks == 25 + assert route.calls.last.request.url.params["types"] == "KEYWORD,TARGET_CPA_OPT_IN" + + +@respx.mock +def test_apply_recommendations_sends_the_ids(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/google/recommendations/apply").mock( + return_value=httpx.Response(200, json={"data": {"applied": 1}}) + ) + applied = client.ads.google.apply_recommendations( + workspace_id="ws_1", + connection_id="conn_1", + customer_id="1234567890", + ids=["customers/1234567890/recommendations/ABC~1"], + ) + assert applied == 1 + assert json.loads(route.calls.last.request.content)["ids"] == [ + "customers/1234567890/recommendations/ABC~1" + ]