From 0a6e2665064bb8b75d731abb936854df2133dcf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 24 Jul 2026 22:26:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?= =?UTF-8?q?):=20YandexDirectClient=20=E2=80=94=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20v5?= =?UTF-8?q?=20(retargeting/campaign/adgroup/ad/moderation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Advertising/YandexDirectClient.php | 176 ++++++++++++++++++ .../Advertising/YandexDirectClientTest.php | 57 ++++++ 2 files changed, 233 insertions(+) create mode 100644 app/app/Services/Advertising/YandexDirectClient.php create mode 100644 app/tests/Unit/Advertising/YandexDirectClientTest.php 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; + }); +});