From 1b235f39b99c5e585bc1b7a5151918324703d6b8 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 4 Oct 2026 00:08:20 +0200 Subject: [PATCH 1/2] feat(ads): add Google Ads recommendations and optimization score Four calls on ads()->google(): recommendations, optimizationScore, and applyRecommendations and dismissRecommendations, which need publish. Adds a float coercion to Model, which the scores and conversion counts need and which no model had called for before. --- src/Model/GoogleOptimizationScore.php | 31 ++++++++ src/Model/GoogleOptimizationScoreCampaign.php | 30 ++++++++ src/Model/GoogleRecommendation.php | 42 +++++++++++ src/Model/GoogleRecommendationImpact.php | 36 ++++++++++ src/Model/Model.php | 14 ++++ src/Resource/GoogleAdsResource.php | 71 +++++++++++++++++++ tests/GoogleAdsTest.php | 36 ++++++++++ 7 files changed, 260 insertions(+) create mode 100644 src/Model/GoogleOptimizationScore.php create mode 100644 src/Model/GoogleOptimizationScoreCampaign.php create mode 100644 src/Model/GoogleRecommendation.php create mode 100644 src/Model/GoogleRecommendationImpact.php diff --git a/src/Model/GoogleOptimizationScore.php b/src/Model/GoogleOptimizationScore.php new file mode 100644 index 0000000..5fc9604 --- /dev/null +++ b/src/Model/GoogleOptimizationScore.php @@ -0,0 +1,31 @@ + $campaigns */ + private function __construct( + array $raw, + public readonly ?float $score, + public readonly ?float $weight, + public readonly array $campaigns, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::float($data, 'score'), + self::float($data, 'weight'), + GoogleOptimizationScoreCampaign::listFrom($data['campaigns'] ?? []), + ); + } +} diff --git a/src/Model/GoogleOptimizationScoreCampaign.php b/src/Model/GoogleOptimizationScoreCampaign.php new file mode 100644 index 0000000..441a344 --- /dev/null +++ b/src/Model/GoogleOptimizationScoreCampaign.php @@ -0,0 +1,30 @@ + $data */ + protected static function float(array $data, string $name): ?float + { + $value = self::field($data, $name); + if (is_int($value) || is_float($value)) { + return (float) $value; + } + if (is_string($value) && is_numeric($value)) { + return (float) $value; + } + + return null; + } + /** @param array $data */ protected static function bool(array $data, string $name): ?bool { diff --git a/src/Resource/GoogleAdsResource.php b/src/Resource/GoogleAdsResource.php index 1753eb2..91ded0d 100644 --- a/src/Resource/GoogleAdsResource.php +++ b/src/Resource/GoogleAdsResource.php @@ -12,6 +12,8 @@ use Fopost\Sdk\Model\GoogleKeyword; use Fopost\Sdk\Model\GoogleKeywordIdea; use Fopost\Sdk\Model\GoogleLocalServicesLead; +use Fopost\Sdk\Model\GoogleOptimizationScore; +use Fopost\Sdk\Model\GoogleRecommendation; use Fopost\Sdk\Model\GoogleSearchTerm; use Fopost\Sdk\Model\GoogleSharedSet; @@ -446,6 +448,75 @@ public function localServicesLeads( ))); } + // ── Recommendations ── + + /** + * Google's own read on what the account should change next. + * + * @param array $types narrows to those recommendation types + * @return array + */ + public function recommendations( + string $connectionId, + string $customerId, + array $types = [], + ?string $workspaceId = null, + ): array { + $extra = $types === [] ? [] : ['types' => implode(',', $types)]; + + return GoogleRecommendation::listFrom(self::unwrap($this->http->get( + '/ads/google/recommendations', + self::params($connectionId, $customerId, $workspaceId, $extra), + ))); + } + + /** The account's score and weight, and the score of each live campaign. */ + public function optimizationScore( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): GoogleOptimizationScore { + return GoogleOptimizationScore::fromArray(self::unwrap($this->http->get( + '/ads/google/optimization-score', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** + * Applies each one, which changes what the live account serves or bids, and + * reports how many landed. Needs `publish` as well as `ads`. + * + * @param array $ids + */ + public function applyRecommendations( + string $workspaceId, + string $connectionId, + string $customerId, + array $ids, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)]; + $result = self::unwrap($this->http->post('/ads/google/recommendations/apply', $body)); + + return is_array($result) && is_int($result['applied'] ?? null) ? $result['applied'] : 0; + } + + /** + * Hides each one so Google stops surfacing it. Needs `publish` as well as `ads`. + * + * @param array $ids + */ + public function dismissRecommendations( + string $workspaceId, + string $connectionId, + string $customerId, + array $ids, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['ids' => array_values($ids)]; + $result = self::unwrap($this->http->post('/ads/google/recommendations/dismiss', $body)); + + return is_array($result) && is_int($result['dismissed'] ?? null) ? $result['dismissed'] : 0; + } + // ── Conversions ── /** @return array */ diff --git a/tests/GoogleAdsTest.php b/tests/GoogleAdsTest.php index bf84c26..8c2f273 100644 --- a/tests/GoogleAdsTest.php +++ b/tests/GoogleAdsTest.php @@ -99,6 +99,42 @@ public function testQueryReturnsRowsAsGoogleSendsThem(): void $this->assertStringContainsString('/ads/insights/query', $this->transport->last()['url']); } + public function testRecommendationsJoinTheTypesFilter(): void + { + $this->transport->push(200, ['data' => [[ + 'id' => 'customers/1234567890/recommendations/ABC~1', + 'type' => 'KEYWORD', + 'campaignId' => '1234567890~campaign~55', + 'dismissed' => false, + 'impact' => ['baseClicks' => 10, 'potentialClicks' => 25], + ]]]); + + $rows = $this->client()->ads()->google()->recommendations( + 'conn_1', + '1234567890', + ['KEYWORD', 'TARGET_CPA_OPT_IN'], + ); + + $this->assertSame('KEYWORD', $rows[0]->type); + $this->assertSame(25.0, $rows[0]->impact?->potentialClicks); + $this->assertStringContainsString('types=KEYWORD%2CTARGET_CPA_OPT_IN', $this->transport->last()['url']); + } + + public function testApplyRecommendationsSendsTheIds(): void + { + $this->transport->push(200, ['data' => ['applied' => 1]]); + + $applied = $this->client()->ads()->google()->applyRecommendations( + 'ws_1', + 'conn_1', + '1234567890', + ['customers/1234567890/recommendations/ABC~1'], + ); + + $this->assertSame(1, $applied); + $this->assertStringContainsString('/ads/google/recommendations/apply', $this->transport->last()['url']); + } + public function testAuthorizeGoogleHasItsOwnRoute(): void { $this->transport->push(200, ['data' => ['url' => 'https://accounts.google.com/o/x']]); From d90ff37c90692ef608755986e8653ed6e8cf22cc Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 4 Oct 2026 00:40:14 +0200 Subject: [PATCH 2/2] style(tests): wrap the over-long line phpcs rejects tests/AdsTikTokTest.php line 35 is 168 characters against a 140 limit, which has had CI red on main since the TikTok ads merge. Not this branch's change, but this branch cannot go green without it. --- tests/AdsTikTokTest.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/AdsTikTokTest.php b/tests/AdsTikTokTest.php index 8885b37..8908b93 100644 --- a/tests/AdsTikTokTest.php +++ b/tests/AdsTikTokTest.php @@ -32,7 +32,14 @@ public function testIdentitiesAndSparkPostsReadTheRightPaths(): void public function testSparkPostIdAndSmartPlusTravelInTheBody(): void { - $this->transport->push(201, ['data' => ['id' => 'ad_1', 'workspaceId' => 'w_1', 'kind' => 'ad', 'name' => 'Spark', 'goal' => 'traffic', 'status' => 'paused']]); + $this->transport->push(201, ['data' => [ + 'id' => 'ad_1', + 'workspaceId' => 'w_1', + 'kind' => 'ad', + 'name' => 'Spark', + 'goal' => 'traffic', + 'status' => 'paused', + ]]); $this->client()->ads()->create( 'w_1', 'conn_1',