diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php new file mode 100644 index 00000000..ba72521d --- /dev/null +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -0,0 +1,176 @@ +call('v5/retargetinglists', 'add', [ + 'RetargetingLists' => [[ + 'Name' => mb_substr($name, 0, 250), + 'Type' => 'AUDIENCE', + 'Rules' => [[ + 'Operator' => 'ALL', + 'Arguments' => [['ExternalId' => $segmentExternalId]], + ]], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** Campaigns.add: TEXT_CAMPAIGN, ручная стратегия HIGHEST_POSITION, недельный бюджет (микросы). */ + public function addCampaign(string $name, string $startDate, int $weeklySpendLimitMicros): int + { + // TODO(verify): SERVING_OFF / NetworkHighestPosition — сверить add-text-campaign.md перед боем + $res = $this->call('v5/campaigns', 'add', [ + 'Campaigns' => [[ + 'Name' => mb_substr($name, 0, 255), + 'StartDate' => $startDate, // YYYY-MM-DD + 'TextCampaign' => [ + 'BiddingStrategy' => [ + // Показ только в сетях (там ретаргетинг). ⚠️ verify: гашение Search = SERVING_OFF. + 'Search' => ['BiddingStrategyType' => 'SERVING_OFF'], + 'Network' => [ + 'BiddingStrategyType' => 'HIGHEST_POSITION', + 'NetworkHighestPosition' => ['WeeklySpendLimit' => $weeklySpendLimitMicros], + ], + ], + ], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AdGroups.add: группа без ключевых слов, регионы показа. + * + * @param array $regionIds + */ + public function addAdGroup(int $campaignId, string $name, array $regionIds): int + { + $res = $this->call('v5/adgroups', 'add', [ + 'AdGroups' => [[ + 'Name' => mb_substr($name, 0, 255), + 'CampaignId' => $campaignId, + 'RegionIds' => array_values($regionIds), + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AudienceTargets.add: привязать условие ретаргетинга к группе + ставка (₽ × 1e6). */ + public function addAudienceTarget(int $adGroupId, int $retargetingListId, int $contextBidMicros): int + { + $res = $this->call('v5/audiencetargets', 'add', [ + 'AudienceTargets' => [[ + 'AdGroupId' => $adGroupId, + 'RetargetingListId' => $retargetingListId, + 'ContextBid' => $contextBidMicros, + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AdImages.upload (json/v501): base64 → AdImageHash. */ + public function uploadAdImage(string $name, string $base64Data): string + { + $res = $this->call('v501/adimages', 'add', [ + 'AdImages' => [['Name' => mb_substr($name, 0, 255), 'ImageData' => $base64Data]], + ]); + + return (string) $res['result']['AddResults'][0]['AdImageHash']; + } + + /** Ads.add: TextAd в группе. Возвращает Id объявления. + * + * @param array $textAd + */ + public function addTextAd(int $adGroupId, array $textAd): int + { + $res = $this->call('v5/ads', 'add', [ + 'Ads' => [['AdGroupId' => $adGroupId, 'TextAd' => $textAd]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** Ads.get: статусы модерации по id. Возвращает [adId => ['status'=>..,'state'=>..,'reason'=>..]]. + * + * @param array $adIds + * @return array> + */ + public function getAdsModeration(array $adIds): array + { + $res = $this->call('v5/ads', 'get', [ + 'SelectionCriteria' => ['Ids' => array_values($adIds)], + 'FieldNames' => ['Id', 'Status', 'State', 'StatusClarification'], + ]); + + $out = []; + foreach ($res['result']['Ads'] ?? [] as $ad) { + $out[(int) $ad['Id']] = [ + 'status' => $ad['Status'] ?? null, + 'state' => $ad['State'] ?? null, + 'reason' => $ad['StatusClarification'] ?? null, + ]; + } + + return $out; + } + + /** Campaigns.suspend — пауза показа. */ + public function suspendCampaign(int $campaignId): void + { + $this->call('v5/campaigns', 'suspend', ['SelectionCriteria' => ['Ids' => [$campaignId]]]); + } + + /** Campaigns.resume — возобновление показа. */ + public function resumeCampaign(int $campaignId): void + { + $this->call('v5/campaigns', 'resume', ['SelectionCriteria' => ['Ids' => [$campaignId]]]); + } + + /** + * Общий вызов JSON API. Бросает RuntimeException при error или не-2xx. + * + * @param array $params + * @return array + */ + private function call(string $servicePath, string $method, array $params): array + { + $resp = Http::withToken($this->token) + ->acceptJson() + ->asJson() + ->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [ + 'method' => $method, + 'params' => $params, + ]); + + $json = $resp->json(); + if (! $resp->successful() || isset($json['error'])) { + $err = $json['error'] ?? ['error_string' => $resp->body()]; + throw new RuntimeException('Yandex Direct '.$servicePath.'/'.$method.' error: '.json_encode($err, JSON_UNESCAPED_UNICODE)); + } + + return $json; + } +} diff --git a/app/tests/Unit/Advertising/YandexDirectClientTest.php b/app/tests/Unit/Advertising/YandexDirectClientTest.php new file mode 100644 index 00000000..ef2d3c40 --- /dev/null +++ b/app/tests/Unit/Advertising/YandexDirectClientTest.php @@ -0,0 +1,57 @@ + Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addRetargetingList('Лидерра кампания #5', 58034825); + + expect($id)->toBe(777); + Http::assertSent(function ($req) { + $body = $req->data(); + + return str_ends_with($req->url(), '/json/v5/retargetinglists') + && $req->hasHeader('Authorization', 'Bearer TESTTOKEN') + && $body['method'] === 'add' + && $body['params']['RetargetingLists'][0]['Type'] === 'AUDIENCE' + && $body['params']['RetargetingLists'][0]['Rules'][0]['Operator'] === 'ALL' + && $body['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]['ExternalId'] === 58034825; + }); +}); + +it('surfaces an API error as RuntimeException', function () { + Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Нет доступа к API', 'error_code' => 53]], 200)]); + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'); + expect(fn () => $client->addCampaign('C', '2026-07-25', 385000000))->toThrow(RuntimeException::class); +}); + +it('sends the weekly spend limit in micros when adding a campaign', function () { + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 42]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addCampaign('Лидерра кампания #5', '2026-07-25', 385000000); + + expect($id)->toBe(42); + Http::assertSent(function ($req) { + $body = $req->data(); + $strategy = $body['params']['Campaigns'][0]['TextCampaign']['BiddingStrategy']; + + return $body['method'] === 'add' + && $body['params']['Campaigns'][0]['StartDate'] === '2026-07-25' + && $strategy['Search']['BiddingStrategyType'] === 'SERVING_OFF' + && $strategy['Network']['BiddingStrategyType'] === 'HIGHEST_POSITION' + && $strategy['Network']['NetworkHighestPosition']['WeeklySpendLimit'] === 385000000; + }); +});