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 96cdd6ec..603127f0 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2523,3 +2523,5 @@ 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 зелёного теста этого не видел — шов между двумя честными половинками остался голым. Доказано