From 9552efc82035fe0d021a62037ee8bd6de64f0c7d 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: Sat, 1 Aug 2026 15:12:40 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0:=20=D1=82?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=BE=D0=BC=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8,=20?= =?UTF-8?q?=D0=B2=D1=81=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BE=D0=BC=20=D0=B2=20=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кампания 713175197 третьи сутки не показывалась при замороженных у клиента деньгах, а программный интерфейс на всех уровнях рапортовал «Идут показы». Правду отдала только подпись на экране кабинета: «Для показа в заданных регионах предоставьте документы» — 13 объявлений придержаны, 2 отклонены. 1. Яндекс отвечает машине ПО-АНГЛИЙСКИ, если не попросить русский. Замер боевым ключом, два одинаковых запроса подряд: без заголовка «Rejected at moderation.», с Accept-Language ru «Отклонено на модерации.». Английская строка уезжала клиенту в переписку и письмом как пояснение модератора, честная заглушка «причину выясняем» не срабатывала никогда, а на следующем обходе английская строка ложилась ПОВЕРХ доклада разведчика — проверено по боевой ленте: 30.07 в 15:02 робот принёс полную причину, в 17:00 её накрыло. Лечение: спрашиваем язык явно плюс второй заслон — английские отписки узнаются как отписки. 2. Отказ включения Яндекс кладёт в Warnings, а код смотрел только в Errors. Ответ целиком: ResumeResults с Warnings 10201 «Объявление не остановлено» при пустом Errors. Исключения нет, портал считал, что справился: двое суток обход каждые два часа поднимал 13 объявлений, ноль записей в журнале. Лечение: resumeAds возвращает, кого включить не дали. 3. Новый исход «принято, но придержано» порталу не был известен вовсе. Вердикт ACCEPTED, ярлыка «Отклонено» нет, разведчик не ходил — клиент видел «Идут показы» при нулевых показах. Теперь по отказу включения клиенту идёт сообщение «Яндекс принял объявление, но пока не показывает его. Причину выясняем» — текст выбран владельцем — и туда же едет разведка. Ловушка, обойденная по дороге: включение спрашиваем ДО разбора объявлений. Наоборот — сообщение о придержке легло бы поверх доклада разведчика, и обход начал бы чередовать их по кругу: защита от дублей смотрит на последнее сообщение. Замеры. Восемь новых сторожей, все написаны ДО починки и падали именно на живых данных. Отдельный сторож на противоположный случай — включённому объявлению разведку не заводим — был зелёным с самого начала. Портал 4083 теста, 4043 прошло, 16 упало: те же шесть давних классов и ровно те же числа, что до работы, было 4069/4029/16. Плюс 14 тестов — ровно новые. Pint и Larastan по изменённым файлам чистые. Главный урок записан в cabinet-flow.md §7.9: в §7.7 лежит правдивый РУССКИЙ ответ Яндекса, снятый ДРУГИМ инструментом, который язык просил. Разбор отписок построили по показаниям прибора, которым продукт не пользуется. Мерить надо той же дорогой, по которой ходит боевой код. На боевой НЕ выкатывалось. Co-Authored-By: Claude Opus 5 (1M context) --- app/app/Jobs/SyncCampaignModerationJob.php | 112 ++++++++++++++---- .../Services/Advertising/ModerationReason.php | 22 ++++ .../Advertising/YandexDirectClient.php | 37 +++++- .../SyncCampaignModerationJobTest.php | 67 +++++++++++ .../Unit/Advertising/ModerationReasonTest.php | 46 +++++++ .../Advertising/YandexDirectClientTest.php | 53 +++++++++ bots/yandex-creatives/docs/cabinet-flow.md | 78 ++++++++++++ cspell-words.txt | 1 + .../2026-07-28-STATE-okno-peredachi.md | 24 +++- 9 files changed, 410 insertions(+), 30 deletions(-) create mode 100644 app/tests/Unit/Advertising/ModerationReasonTest.php diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php index 3705d351..fee915ad 100644 --- a/app/app/Jobs/SyncCampaignModerationJob.php +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -16,6 +16,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Throwable; @@ -77,11 +78,12 @@ class SyncCampaignModerationJob implements ShouldQueue $banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all() ); - // Принятые модерацией, но с выключенным показом — их надо поднять. - // Собираем по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: - // у давно запущенной кампании «принято» записано у нас неделю назад, - // перехода уже не будет, и по переходу она осталась бы выключенной навсегда. - $vyklyuchennye = []; + // 🔴 Включение пробуем ДО разбора баннеров, а не после. Отказ включения — + // единственный машинный признак «Яндекс придержал показ», и он нужен уже + // при составлении текста для клиента. Сделай наоборот — сообщение о придержке + // легло бы ПОВЕРХ доклада разведчика, а следующий обход начал бы чередовать + // их по кругу: защита от дублей смотрит на последнее сообщение. + $otkazyVklyucheniya = $this->probuemVklyuchit($direct, $banners, $moderation, (int) $campaign->id); foreach ($banners as $banner) { $info = $moderation[(int) $banner->yandex_ad_id] ?? null; @@ -107,19 +109,23 @@ class SyncCampaignModerationJob implements ShouldQueue // получает честное «причину выясняем», а настоящую принесёт разведка. $raw = is_string($info['reason'] ?? null) ? $info['reason'] : null; - $reason = $status === AdCampaignBanner::MOD_REJECTED - ? ModerationReason::forRejected($raw) - : ModerationReason::normalize($raw); + // 🔴 Яндекс принял объявление, но включить не дал — значит показов нет, + // а `ACCEPTED` в ответе выдал бы их за идущие. Статус не трогаем + // (passthrough Р27: что сказал Яндекс, то и записали), но клиенту + // говорим правду вместо машинного «Принято на модерации.». + $priderzhan = isset($otkazyVklyucheniya[(int) $banner->yandex_ad_id]); + + $reason = match (true) { + $status === AdCampaignBanner::MOD_REJECTED => ModerationReason::forRejected($raw), + $priderzhan => ModerationReason::POKAZ_PRIDERZHAN, + default => ModerationReason::normalize($raw), + }; $banner->update([ 'moderation_status' => $status, 'moderation_reason' => $reason === null ? null : mb_substr($reason, 0, 255), ]); - if ($status === AdCampaignBanner::MOD_ACCEPTED && ($info['state'] ?? null) === 'OFF') { - $vyklyuchennye[] = (int) $banner->yandex_ad_id; - } - // Пояснение модератора кладём в ленту кампании ЦЕЛИКОМ: в колонке // баннера оно обрезано до 255 знаков ради ярлыка, а клиенту нужен // весь текст — именно по нему он поймёт, что переделывать. @@ -137,7 +143,10 @@ class SyncCampaignModerationJob implements ShouldQueue // причину, получил бы поверх неё «причину не назвали» и ещё // одно письмо. Обычная защита от дублей тут не спасает — она // смотрит на ПОСЛЕДНЕЕ сообщение, а последним лежит доклад робота. - $zaglushka = $reason === ModerationReason::PRICHINA_NEIZVESTNA; + $zaglushka = in_array($reason, [ + ModerationReason::PRICHINA_NEIZVESTNA, + ModerationReason::POKAZ_PRIDERZHAN, + ], true); if (! $zaglushka || ! $messages->hasFromYandex($campaign, (int) $banner->id)) { $messages->postFromYandex($campaign, (int) $banner->id, $reason); @@ -157,7 +166,10 @@ class SyncCampaignModerationJob implements ShouldQueue // Отдельный try по той же причине, что и у ленты: очередь заданий — вещь // второстепенная, а вердикт модерации и возврат денег нет. Беда с очередью // не должна стоить клиенту незаписанного отказа и невернувшихся денег. - if ($status === AdCampaignBanner::MOD_REJECTED) { + // + // Придержанное объявление разведываем по той же причине: машине Яндекс + // говорит «принято», а на экране висит «предоставьте документы». + if ($status === AdCampaignBanner::MOD_REJECTED || $priderzhan) { try { app(CreativeJobService::class)->enqueueInspection($campaign, $banner); } catch (Throwable $e) { @@ -167,18 +179,6 @@ class SyncCampaignModerationJob implements ShouldQueue } } } - - // Отдельный try: показ — вещь важная, но неудача включения не должна - // стоить кампании записанного вердикта модерации и сорвать обход остальных. - if ($vyklyuchennye !== []) { - try { - $direct->resumeAds($vyklyuchennye); - } catch (Throwable $e) { - Log::warning('Не смогли включить показ принятых объявлений: '.$e->getMessage(), [ - 'campaign' => $campaign->id, 'ads' => count($vyklyuchennye), - ]); - } - } } catch (Throwable $e) { // ПДн в лог не попадают — только id кампании. Log::warning('SyncCampaignModerationJob: '.$e->getMessage(), ['campaign' => $campaign->id]); @@ -230,6 +230,66 @@ class SyncCampaignModerationJob implements ShouldQueue } } + /** + * Поднять показ у принятых, но выключенных объявлений — и вернуть тех, кого Яндекс + * поднять не дал. + * + * 🔴 У объявления два независимых признака: `Status` — вердикт модерации, `State` — + * идёт ли показ. Созданное программой объявление рождается выключенным и таким + * остаётся, пока его не включить; включение кампании объявления не поднимает. + * + * Собираем по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: у давно + * запущенной кампании «принято» записано неделю назад, перехода уже не будет, + * и по переходу она осталась бы выключенной навсегда. + * + * Отдельный try: показ — вещь важная, но неудача включения не должна стоить кампании + * записанного вердикта модерации и сорвать обход остальных клиентов. + * + * @param Collection $banners + * @param array> $moderation + * @return array номер объявления → слова Яндекса, почему не включил + */ + private function probuemVklyuchit( + YandexDirectClient $direct, + Collection $banners, + array $moderation, + int $campaignId, + ): array { + $vyklyuchennye = []; + + foreach ($banners as $banner) { + $info = $moderation[(int) $banner->yandex_ad_id] ?? null; + + if (($info['status'] ?? null) === AdCampaignBanner::MOD_ACCEPTED && ($info['state'] ?? null) === 'OFF') { + $vyklyuchennye[] = (int) $banner->yandex_ad_id; + } + } + + if ($vyklyuchennye === []) { + return []; + } + + try { + $otkazy = $direct->resumeAds($vyklyuchennye); + + if ($otkazy !== []) { + Log::warning('Яндекс не дал включить показ принятых объявлений', [ + 'campaign' => $campaignId, + 'ads' => array_keys($otkazy), + 'слова Яндекса' => array_values(array_unique($otkazy)), + ]); + } + + return $otkazy; + } catch (Throwable $e) { + Log::warning('Не смогли включить показ принятых объявлений: '.$e->getMessage(), [ + 'campaign' => $campaignId, 'ads' => count($vyklyuchennye), + ]); + + return []; + } + } + /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */ private function configString(string $key, string $default = ''): string { diff --git a/app/app/Services/Advertising/ModerationReason.php b/app/app/Services/Advertising/ModerationReason.php index 7f9d15d7..2258e142 100644 --- a/app/app/Services/Advertising/ModerationReason.php +++ b/app/app/Services/Advertising/ModerationReason.php @@ -36,6 +36,15 @@ final class ModerationReason 'Отклонено на модерации', 'Ожидает модерации.', 'Ожидает модерации', + // 🔴 Те же отписки по-английски. Замер 01.08.2026 на бою: без заголовка языка + // Яндекс отвечает именно так, и эта строка уезжала клиенту в переписку и письмом + // как «пояснение модератора». Язык мы теперь спрашиваем (YandexDirectClient::call), + // но это второй заслон: заголовок могут перестать слушать, и тогда клиент снова + // получит машинную строку вместо честного «выясняем». + 'Rejected at moderation.', + 'Rejected at moderation', + 'Pending moderation.', + 'Pending moderation', ]; /** Что показываем клиенту, пока настоящей причины нет. Первая строка — короткая: @@ -43,6 +52,19 @@ final class ModerationReason public const PRICHINA_NEIZVESTNA = "Яндекс отклонил рекламу, но причину не назвал.\n" .'Выясняем — как только узнаем, напишем здесь.'; + /** + * Объявление принято, но показывать его Яндекс не даёт. Машине он про это молчит: + * `ads.get` отвечает `ACCEPTED`, и портал считал такую рекламу работающей. Правда + * живёт подписью на экране кабинета — «Для показа в заданных регионах предоставьте + * документы» (снято живьём 01.08.2026, кампания 713175197, 13 объявлений, третьи + * сутки нулевых показов при замороженных деньгах клиента). + * + * Текст выбран владельцем 01.08.2026: честно и коротко. Первая строка короткая — + * она идёт подписью в списке кампаний. Настоящую причину принесёт разведчик. + */ + public const POKAZ_PRIDERZHAN = "Яндекс принял объявление, но пока не показывает его.\n" + .'Причину выясняем — как только узнаем, напишем здесь.'; + /** * Причина отказа для клиента: настоящая, если Яндекс её назвал, иначе — честное * «выясняем». Вызывать ТОЛЬКО для отклонённых объявлений: у принятого пустое diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php index fa3aa9e5..5d15be6d 100644 --- a/app/app/Services/Advertising/YandexDirectClient.php +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -341,15 +341,38 @@ final class YandexDirectClient * пишет «Идут показы», а внутри все 15 объявлений выключены — показов ноль при * замороженных у клиента деньгах. Команды включить объявления не было в коде вообще. * + * 🔴🔴 Отказ включения Яндекс кладёт в «предупреждения», а НЕ в «ошибки» — и проверка + * по ошибкам его не видит. Замер 01.08.2026 боевым ключом, ответ целиком: + * + * {"ResumeResults":[{"Id":17787641513, + * "Warnings":[{"Code":10201,"Message":"Объявление не остановлено"}],"Errors":[]}]} + * + * `Errors` пуст, исключения нет, портал считает, что справился. На бою это шло двое + * суток: обход каждые два часа, около двадцати попыток поднять 13 объявлений, ноль + * записей в журнале, показов ноль — а клиент видел «Идут показы». Поэтому отказы + * возвращаем наружу: вызывающий обязан узнать, кого включить не дали. + * * @param list $adIds + * @return array номер объявления → слова Яндекса, почему не включил */ - public function resumeAds(array $adIds): void + public function resumeAds(array $adIds): array { if ($adIds === []) { - return; + return []; } - $this->call('v5/ads', 'resume', ['SelectionCriteria' => ['Ids' => $adIds]]); + $res = $this->call('v5/ads', 'resume', ['SelectionCriteria' => ['Ids' => $adIds]]); + + $otkazy = []; + foreach ($res['result']['ResumeResults'] ?? [] as $item) { + $preduprezhdenie = $item['Warnings'][0]['Message'] ?? null; + + if (isset($item['Id']) && is_string($preduprezhdenie) && $preduprezhdenie !== '') { + $otkazy[(int) $item['Id']] = $preduprezhdenie; + } + } + + return $otkazy; } /** Campaigns.suspend — пауза показа. */ @@ -418,6 +441,14 @@ final class YandexDirectClient private function call(string $servicePath, string $method, array $params): array { $resp = Http::withToken($this->token) + // 🔴 Язык ответа спрашиваем ЯВНО. Без этого заголовка Яндекс отвечает + // по-английски, и его слова уезжают клиенту в переписку и письмом: + // «Rejected at moderation.», «Accepted at moderation.». Замер 01.08.2026 + // боевым ключом, два одинаковых запроса подряд по объявлению 17787641507: + // без заголовка — «\nRejected at moderation.», с ним — «\nОтклонено на модерации.». + // 🪤 Записанный 28.07 русский ответ (cabinet-flow.md §7.7) снят ДРУГИМ + // инструментом, который язык просил, — прибором, которым продукт не пользуется. + ->withHeaders(['Accept-Language' => 'ru']) ->acceptJson() ->asJson() ->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [ diff --git a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php index d65e86cd..46495440 100644 --- a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php +++ b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php @@ -580,3 +580,70 @@ it('не включает отклонённое объявление', function Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'resume'); }); + +/** + * 🔴 Слепое пятно, вскрытое заходом в кабинет 01.08.2026: Яндекс объявление ПРИНЯЛ, + * но показывать не даёт. Программе он про это не говорит ни слова — `ads.get` отвечает + * бодрым `ACCEPTED`, и портал считает, что всё хорошо. Правда живёт только подписью + * на экране кабинета: «Для показа в заданных регионах предоставьте документы». + * + * Единственный признак, доступный машине, — отказ на команду включения, и приходит он + * «предупреждением», не «ошибкой»: + * + * {"ResumeResults":[{"Id":...,"Warnings":[{"Code":10201, + * "Message":"Объявление не остановлено"}],"Errors":[]}]} + * + * На бою это шло двое суток: обход каждые два часа, тринадцать объявлений, ноль показов, + * ноль слов клиенту — а в списке кампаний у него «Идут показы». + */ +it('придержанное объявление не выдаёт за работающее', function () { + configureYandexForModeration(); + Http::fake(function ($request) { + return ($request->data()['method'] ?? null) === 'resume' + ? Http::response(['result' => ['ResumeResults' => [ + ['Id' => 9201, 'Warnings' => [['Code' => 10201, 'Message' => 'Объявление не остановлено']], 'Errors' => []], + ]]]) + : Http::response(['result' => ['Ads' => [ + ['Id' => 9201, 'State' => 'OFF', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято на модерации.'], + ]]]); + }); + + [$campaign] = makeModeratedCampaignWithBanners([9201]); + + (new SyncCampaignModerationJob)->handle(); + + // Клиенту сказали правду сразу, не дожидаясь разведчика. + $body = (string) AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_YANDEX)->orderByDesc('id')->first()?->body; + + expect($body)->toContain('не показывает') + ->and($body)->not->toContain('Принято на модерации'); + + // И послали робота выяснить настоящую причину — своими словами Яндекс её не отдаёт. + expect(AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_INSPECT) + ->where('yandex_ad_id', 9201)->count())->toBe(1); +}); + +/** + * Обратная сторона того же сторожа: включили — значит работает, и пугать клиента нечем. + * Без этой проверки «чинить» можно было бы объявлением любого исхода. + */ +it('включённому объявлению разведку не заводит и клиента не пугает', function () { + configureYandexForModeration(); + Http::fake(function ($request) { + return ($request->data()['method'] ?? null) === 'resume' + ? Http::response(['result' => ['ResumeResults' => [['Id' => 9202, 'Warnings' => [], 'Errors' => []]]]]) + : Http::response(['result' => ['Ads' => [ + ['Id' => 9202, 'State' => 'OFF', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято на модерации.'], + ]]]); + }); + + [$campaign] = makeModeratedCampaignWithBanners([9202]); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0) + ->and((string) AdCampaignBanner::where('campaign_id', $campaign->id)->first()?->moderation_reason) + ->not->toContain('не показывает'); +}); diff --git a/app/tests/Unit/Advertising/ModerationReasonTest.php b/app/tests/Unit/Advertising/ModerationReasonTest.php new file mode 100644 index 00000000..37e19b12 --- /dev/null +++ b/app/tests/Unit/Advertising/ModerationReasonTest.php @@ -0,0 +1,46 @@ +toBe(ModerationReason::PRICHINA_NEIZVESTNA); +})->with([ + 'русская с точкой' => "\nОтклонено на модерации.", + 'русская без точки' => 'Отклонено на модерации', + 'русская про ожидание' => 'Ожидает модерации.', + 'английская с точкой' => "\nRejected at moderation.", + 'английская без точки' => 'Rejected at moderation', + 'английская про ожидание' => 'Pending moderation.', + 'пусто' => '', +]); + +/** + * 🔴 Настоящую причину не трогаем ни на каком языке. Ровно ради неё весь разбор + * и затевался: её приносит робот-разведчик с экрана кабинета, и она обязана доехать + * до клиента слово в слово, со всеми абзацами. + */ +it('passes a real moderator explanation through untouched', function () { + $nastoyashchaya = "Нет предупреждения: финансовые услуги\n\n" + .'Объявление о финансовых услугах должно содержать наименование юридического лица.'; + + expect(ModerationReason::forRejected($nastoyashchaya))->toBe($nastoyashchaya); +}); + +/** Края обрезаем, внутренние переносы нет — модератор перечисляет претензии абзацами. */ +it('trims the edges but keeps the paragraphs', function () { + expect(ModerationReason::normalize("\n первая\n\nвторая \n"))->toBe("первая\n\nвторая"); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectClientTest.php b/app/tests/Unit/Advertising/YandexDirectClientTest.php index 391498b9..98cf6e4d 100644 --- a/app/tests/Unit/Advertising/YandexDirectClientTest.php +++ b/app/tests/Unit/Advertising/YandexDirectClientTest.php @@ -35,3 +35,56 @@ it('surfaces an API error as RuntimeException', function () { $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'); expect(fn () => $client->addCpmBannerCampaign('C', '2026-08-01', '2026-08-14', 72000000, 90000000, 3, 14))->toThrow(RuntimeException::class); }); + +/** + * 🔴 Язык ответа спрашиваем явно. Без заголовка Яндекс отвечает ПО-АНГЛИЙСКИ, и эти слова + * уезжают клиенту в переписку и письмом: «Rejected at moderation.», «Accepted at moderation.». + * + * Замер 01.08.2026, боевой ключ, два одинаковых запроса подряд по объявлению 17787641507: + * + * без заголовка → StatusClarification = "\nRejected at moderation." + * Accept-Language: ru → StatusClarification = "\nОтклонено на модерации." + * + * 🪤 В `cabinet-flow.md` §7.7 записан русский ответ — и запись не врёт: тот замер делали + * ДРУГИМ инструментом, который язык просил. Разбор отписок построили по показаниям прибора, + * которым боевой код не пользуется. Мерить надо той же дорогой, по которой ходит продукт. + */ +it('asks Yandex to answer in Russian', function () { + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => []]], 200)]); + + (new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->getAdsModeration([1]); + + Http::assertSent(fn ($req) => $req->hasHeader('Accept-Language', 'ru')); +}); + +/** + * 🔴 Отказ включения приходит в «предупреждениях», а не в «ошибках» — и молча теряется. + * + * Замер 01.08.2026, боевой ключ, объявление 17787641513, ответ целиком: + * + * {"result":{"ResumeResults":[{"Id":17787641513, + * "Warnings":[{"Code":10201,"Message":"Объявление не остановлено"}],"Errors":[]}]}} + * + * `Errors` пуст, значит проверка по ошибкам молчит и портал считает, что справился. + * На бою это шло двое суток: обход каждые два часа, около двадцати попыток включить + * 13 объявлений, ноль записей в журнале, показов ноль. Клиент при этом видит «Идут показы». + */ +it('reports ads Yandex refused to resume instead of failing silently', function () { + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['ResumeResults' => [ + ['Id' => 111, 'Warnings' => [['Code' => 10201, 'Message' => 'Объявление не остановлено']], 'Errors' => []], + ['Id' => 222, 'Warnings' => [], 'Errors' => []], + ]]], 200)]); + + $otkazy = (new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->resumeAds([111, 222]); + + expect($otkazy)->toBe([111 => 'Объявление не остановлено']); +}); + +/** Всех включили — отказов нет, и это не путается с «мы не спросили». */ +it('returns no refusals when every ad resumed', function () { + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['ResumeResults' => [ + ['Id' => 111, 'Warnings' => [], 'Errors' => []], + ]]], 200)]); + + expect((new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->resumeAds([111]))->toBe([]); +}); diff --git a/bots/yandex-creatives/docs/cabinet-flow.md b/bots/yandex-creatives/docs/cabinet-flow.md index a94785a6..5f1247b2 100644 --- a/bots/yandex-creatives/docs/cabinet-flow.md +++ b/bots/yandex-creatives/docs/cabinet-flow.md @@ -507,3 +507,81 @@ https://direct.yandex.ru/dna/banners-edit?ulogin=<логин>&campaigns-ids=<н Строк причины в обоих отказах — по одной. Отказ **с несколькими** причинами живьём не встречался: раскрывается только первая строка, поведение остальных неизвестно. + +### 7.9. 🔴🔴 Снято живьём 01.08.2026 — «принято, но не показывается» и язык ответа + +Заход в кабинет вместе с владельцем, кампания **№ 713175197 «Лидерра #6»** (боевая, +заведена нарочно ради отказа). Третьи сутки нулевых показов при замороженных у клиента +3333.36 ₽. Программный интерфейс на всех уровнях отвечал бодро: кампания `ACCEPTED`/`ON` +«Идут показы», группа `ServingStatus: ELIGIBLE`, условие показа `IsAvailable: YES`, +13 объявлений из 15 `ACCEPTED`. И при этом у **всех пятнадцати** `State: OFF`. + +**Правду отдал только экран кабинета.** Подпись у группы № 5778555449: + +```text +Для показа в заданных регионах предоставьте документы +Показы идут с ограничениями +``` + +Раскрытие подписи (клик по `AggregatedStatusContent`, как и у отказа) даёт разбивку: + +```text +Для показа в заданных регионах предоставьте документы + 13 объявлений остановлены +Нет предупреждения: финансовые услуги + 2 объявления отклонены +``` + +🔑 **Это НОВЫЙ вид «показов нет», отличный от отказа модерации.** У объявления вердикт +`ACCEPTED`, ярлыка «Отклонено» нет, разведчик к нему не ходил — и клиенту портал сообщал, +что всё принято. Класс тот же, что у «тихого ноля»: система рапортует успех при нулевой +работе. + +#### Единственный машинный признак — отказ на команду включения + +Замер 01.08.2026 боевым ключом, объявление 17787641513, ответ целиком: + +```json +{"result":{"ResumeResults":[{"Id":17787641513, + "Warnings":[{"Code":10201,"Message":"Объявление не остановлено"}],"Errors":[]}]}} +``` + +🪤 **Отказ лежит в `Warnings`, а `Errors` пуст.** Проверка по ошибкам молчит, исключения +нет, и портал считает, что справился. На бою это шло **двое суток**: обход каждые два часа +(`0 */2 * * *`), около двадцати попыток поднять 13 объявлений, **ноль записей в журнале**. + +🔑 Заодно это окончательно опровергло разгадку от 30.07 («не хватает команды включения»). +Команда есть и отправляется — Яндекс её отклоняет, потому что объявление не «на паузе», +а **придержано**. + +#### 🪤🪤 Яндекс отвечает машине ПО-АНГЛИЙСКИ, если не попросить русский + +Замер 01.08.2026, два одинаковых запроса подряд, разница только в заголовке: + +| Запрос | `StatusClarification` | +|---|---| +| без заголовка (так ходил боевой портал) | `"\nRejected at moderation."` · `"Accepted at moderation."` | +| `Accept-Language: ru` | `"\nОтклонено на модерации."` · `"Принято на модерации."` | + +Английская строка уезжала клиенту в переписку и письмом как «пояснение модератора». +Разбор отписок (`ModerationReason`) написан под русские формулировки — значит честная +заглушка «причину выясняем» **не срабатывала никогда**, а на следующем обходе английская +строка ложилась ПОВЕРХ доклада разведчика (проверено по боевой ленте: 30.07 в 15:02 робот +принёс полную причину, в 17:00 её накрыло «Rejected at moderation.»). + +🔑🔑 **Главный урок, дороже самой поломки.** В §7.7 записан РУССКИЙ ответ, и запись +не врёт — но тот замер делали **другим инструментом**, который язык просил. Разбор +построили по показаниям прибора, **которым продукт не пользуется**. Мерить надо той же +дорогой, по которой ходит боевой код, иначе замер честен и бесполезен одновременно. + +#### Мелочи, снятые попутно + +- 🪤 Кампания лежит на учётке `sasha261185`; наш код ходит в неё под клиентом + `info@lkomega.ru`, а в кабинете под этим логином — «Доступ ограничен». Работать + не мешает, но открывать кабинет надо под `sasha261185`. +- 🪤 `ads.get` с фильтрами `States`+`Statuses` вернул пусто там, где без фильтра + 13 записей. Фильтрам выборки не доверять — брать всё и отбирать у себя. +- Ограничение частоты у кампании — **1 показ на человека**. Объясняет «мало», но не «ноль». +- ⚠️ Разведчик писался под окно ОТКАЗА. Придержанное объявление живьём роботом ещё + не читали: подпись лежит в том же месте, но поведение не проверено. Не поймёт — + задание уедет в админский экран «Ждёт разбора», как и задумано. diff --git a/cspell-words.txt b/cspell-words.txt index dbf16fc4..48084ba5 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2515,3 +2515,4 @@ otkaz otkaza razvernuto zakryt +админский diff --git a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md index b698132e..fb412d4c 100644 --- a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md +++ b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md @@ -3,7 +3,29 @@ Файл пишется в конце каждого захода. Читать его **первым делом** после компакта: всё, что нужно следующей сессии, лежит здесь, а не в переписке. -🔴 **Обновлено: 29.07.2026, заход 7 — РАЗБОР ШВА С ВЕТКОЙ ТЕЛЕГРАМ-РЕКЛАМЫ. Найдена и +🔴 **Обновлено: 01.08.2026, ветка `fix/tg-zagolovok-obyavleniya` — ЗАХОД В КАБИНЕТ ВСКРЫЛ +ТРИ ПОЛОМКИ, все починены.** Кампания № 713175197 третьи сутки не показывалась, программный +интерфейс при этом рапортовал «Идут показы». Правду отдал только экран кабинета: **«Для +показа в заданных регионах предоставьте документы»** — 13 объявлений придержаны, 2 отклонены. + +1. **Яндекс отвечает машине по-английски**, если не попросить русский. Английские + «Rejected at moderation.» уезжали клиенту в переписку и письмом, честная заглушка + «причину выясняем» не срабатывала никогда, а на следующем обходе английская строка + ложилась ПОВЕРХ доклада разведчика. +2. **Отказ включения лежит в `Warnings`, а код смотрел только в `Errors`** — двое суток + по двадцать попыток поднять 13 объявлений, ноль записей в журнале. +3. **Новый исход «принято, но придержано»** порталу не был известен вовсе: клиент видел + «Идут показы» при нулевых показах. Теперь по нему идёт сообщение клиенту и разведка. + +🔑🔑 **Урок дороже поломок:** в §7.7 `cabinet-flow.md` записан правдивый РУССКИЙ ответ, +снятый **другим инструментом**, который язык просил. Разбор отписок построили по показаниям +прибора, которым продукт не пользуется. **Мерить надо той же дорогой, по которой ходит +боевой код.** Замеры и дословные подписи — `cabinet-flow.md` **§7.9**. + +Портал **4083 / 4043 прошло / 16 упало** (те же шесть давних классов, что и до работы). +На боевой НЕ выкатывалось. + +Прежняя запись: 29.07.2026, заход 7 — **РАЗБОР ШВА С ВЕТКОЙ ТЕЛЕГРАМ-РЕКЛАМЫ. Найдена и починена поломка денег в ЭТОЙ ветке.** Пауза кампании и следом возобновление падали на дубле брони: клиент не мог включить свою кампанию обратно. Ни один из 391 зелёного теста этого не видел — шов между двумя честными половинками остался голым. Доказано