diff --git a/bots/mts-telegram-ads/_diag-headless-vs-okno.mjs b/bots/mts-telegram-ads/_diag-headless-vs-okno.mjs new file mode 100644 index 00000000..dcfb530f --- /dev/null +++ b/bots/mts-telegram-ads/_diag-headless-vs-okno.mjs @@ -0,0 +1,39 @@ +// ОПЫТ ПО ОДНОЙ ОСИ (07.08.2026): пускает ли МТС браузер БЕЗ ОКНА и с окном. +// +// Повод: промт «МТС отбивает браузер робота» утверждает, что кабинет режет браузер как +// таковой. Но сторож сессии (bin/keepalive.js) поднимает браузер `headless: true`, а +// настоящая работа робота (runReadStatus/runTask через openBrowser(config, {})) — с +// окном, потому что в browser.js умолчание `headless = false`. Разведка этой смены с +// окном в кабинет ЗАХОДИЛА ровно в те часы, когда сторож писал «не пускает». +// +// Меняем РОВНО ОДНУ вещь — окно — и смотрим. Только чтение: открыть страницу и +// спросить, вошли ли мы. Ничего не нажимаем, не создаём, не отправляем. +import 'dotenv/config'; +import { loadConfig } from './src/config.js'; +import { openBrowser } from './src/browser.js'; +import { ensureLoggedIn } from './src/session.js'; + +const cfg = loadConfig(); + +async function proba(headless) { + const podpis = headless ? 'БЕЗ ОКНА (как сторож)' : 'С ОКНОМ (как настоящая работа)'; + let context; + try { + const b = await openBrowser(cfg, { headless }); + context = b.context; + await ensureLoggedIn(b.page, cfg); + console.log(`${podpis}: ПУСКАЕТ`); + } catch (e) { + console.log(`${podpis}: НЕ ПУСКАЕТ — ${e.message}`); + } finally { + try { await context?.close(); } catch {} + } +} + +// Строго по очереди: профиль браузера один, двух сразу он не выдержит. +// Порядок задаётся доводом: `node _diag-headless-vs-okno.mjs naoborot` — сперва с окном. +// Это нужно, чтобы отсечь соперничающее объяснение «первая попытка всегда мимо». +const naoborot = process.argv[2] === 'naoborot'; +await proba(!naoborot); +await new Promise((r) => setTimeout(r, 3000)); +await proba(naoborot); diff --git a/bots/mts-telegram-ads/bin/keepalive-task.ps1 b/bots/mts-telegram-ads/bin/keepalive-task.ps1 index 119bba86..80d1c927 100644 --- a/bots/mts-telegram-ads/bin/keepalive-task.ps1 +++ b/bots/mts-telegram-ads/bin/keepalive-task.ps1 @@ -32,11 +32,30 @@ try { } Set-Location $papka - $vyvod = & 'C:\liderra\node20\node.exe' 'bin/keepalive.js' 2>&1 | Out-String - $kod = $LASTEXITCODE + + # 🪤 Замерено 06.08.2026. В PowerShell 5.1 связка `2>&1` на ВНЕШНЕЙ программе при + # $ErrorActionPreference='Stop' оборачивает первую же строку stderr в исключение. + # Итог был такой: в журнал попадало «ОБЁРТКА УПАЛА» вместо настоящей причины, + # код выхода терялся совсем (а он различает временную беду и настоящий разлогин), + # вторая и последующие строки причины пропадали, кириллица превращалась + # в кракозябры. Двое суток журнал выглядел так, будто сломан сам сторож. + # + # Лечение: сливаем потоки на стороне cmd — PowerShell видит только stdout и не + # делает из него исключений. `chcp 65001` — чтобы русские буквы дошли целыми. + $prezhnyaya = [Console]::OutputEncoding + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + try { + $vyvod = & cmd.exe /c "chcp 65001 > nul & `"C:\liderra\node20\node.exe`" bin\keepalive.js 2>&1" | Out-String + $kod = $LASTEXITCODE + } finally { + [Console]::OutputEncoding = $prezhnyaya + } + Zapishi ("код $kod :: " + ($vyvod -replace '\s+', ' ').Trim()) exit $kod } catch { + # Сюда попадаем, только если сломалась САМА обёртка (не нашёлся node, нет папки). + # Беды кабинета сюда больше не приходят — они идут строкой «код N :: ...» выше. Zapishi ('ОБЁРТКА УПАЛА :: ' + $_.Exception.Message) exit 1 } diff --git a/bots/mts-telegram-ads/bin/keepalive.js b/bots/mts-telegram-ads/bin/keepalive.js index 376323bd..fe7bc0c6 100644 --- a/bots/mts-telegram-ads/bin/keepalive.js +++ b/bots/mts-telegram-ads/bin/keepalive.js @@ -4,6 +4,7 @@ import { openBrowser } from '../src/browser.js'; import { keepaliveBrowserOpts } from '../src/keepalive-plan.js'; import { ensureLoggedIn } from '../src/session.js'; import { createMailer, smtpTransport } from '../src/mailer.js'; +import { otpravitTrevogu } from '../src/trevoga.js'; // Сторож живой сессии кабинета. 🔑 Правка 02.08.2026: раньше он звал isLoggedIn один // раз и на ЛЮБОЙ отрицательный ответ слал письмо «Вход в кабинет слетел». Кабинет МТС @@ -15,10 +16,23 @@ const config = loadConfig(); let context; try { - // 🔴 Правка 07.08.2026: было `{ headless: true }` — сторож ходил БЕЗ ОКНА, а работа - // робота ходит С ОКНОМ. Защита МТС считает это разными браузерами: безоконный на - // протухшем пропуске ловил 403, и владелец получал письмо каждый час о беде, которой - // у работы не было. Теперь опции берутся из keepalive-plan.js — там же и замер. + // 🔴🔴🔴 НЕ ВОЗВРАЩАТЬ `{ headless: true }`. Правка 07.08.2026, разрешена владельцем. + // + // Сторож ходил БЕЗ ОКНА, а настоящая работа робота ходит С ОКНОМ (умолчание + // openBrowser). Для защиты МТС это два разных браузера: сторож ловил отказ 403 там, + // где работа спокойно проходила, и владелец получал письмо КАЖДЫЙ ЧАС о беде, + // которой у работы не было. + // + // Замер, одна ось, два порядка, тот же профиль и тот же адрес выхода: + // сперва без окна → без окна НЕ ПУСКАЕТ, следом с окном ПУСКАЕТ; + // сперва с окном → пускает и то, и другое. + // Живой заход обновляет пропуск в профиле; безоконный сам получить его не может, а на + // свежем — едет. Журнал службы сходится до минуты: последнее «жив» в 10:40 МСК сразу + // после последнего задания в 10:30, дальше заданий нет — и с 11:45 отказы подряд. + // + // Правило: сторож обязан ходить ТОЧНО так же, как ходит работа. Иначе он мерит не то, + // что работает, и его тревога ничего не значит. Сторож этого правила — + // test/keepalive-plan.test.js, он краснеет при возврате безоконного браузера. const b = await openBrowser(config, keepaliveBrowserOpts()); context = b.context; await ensureLoggedIn(b.page, config); @@ -26,10 +40,15 @@ try { } catch (e) { // e.message уже человеческий и разный для трёх бед — его и шлём, без выдумок. console.error('keepalive:', e.message); + // 🔴 Здесь стоял голый `catch {}`. Из-за него двое суток (04.08 20:01 → 06.08) сторож + // 71 раз подряд ловил мёртвый кабинет, каждый раз пытался слать письмо, каждый раз + // получал от Яндекса «553 Sender address rejected» — и об этом не узнал никто. try { const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); - await mailer.alarm({ step: 'keepalive', reason: e.message, screenshotPath: null }); - } catch {} + await otpravitTrevogu({ mailer }, { step: 'keepalive', reason: e.message, screenshotPath: null }); + } catch (ePochta) { + console.error('почтовик не собрался:', ePochta.message); + } // Временная беда (зависшая страница) — не повод считать сторожа упавшим: // следующий проход разберётся. Настоящий разлогин/блокировка — повод. process.exitCode = e.retryable === true ? 0 : 1; diff --git a/bots/mts-telegram-ads/bin/nastroit-avtovhod.ps1 b/bots/mts-telegram-ads/bin/nastroit-avtovhod.ps1 new file mode 100644 index 00000000..3f87e215 --- /dev/null +++ b/bots/mts-telegram-ads/bin/nastroit-avtovhod.ps1 @@ -0,0 +1,91 @@ +# Автоматический вход в систему при загрузке — чтобы робот пережил перезагрузку. +# +# ЗАЧЕМ. Робот управляет НАСТОЯЩИМ браузером с сохранённым входом в кабинет МТС. +# Печенья этого входа Windows шифрует ключом, который появляется только при входе +# ПО ПАРОЛЮ. У задач «без пароля» (тип S4U) такого ключа нет — браузер каждый раз +# видит мёртвый кабинет: с 04.08 20:01 по 06.08 сторож дал 71 отказ подряд. +# +# А ВНЕ СЕАНСА браузер не заводится вовсе. Замерено 06.08.2026 пробой от системной +# учётки: «Target page, context or browser has been closed». Поэтому задачу +# «запускать, даже когда никто не вошёл» сделать нельзя ни с каким паролем. +# +# Остаётся одно: пусть система сама входит в сеанс при загрузке. Тогда срабатывает +# уже работающая задача Liderra-MTS-Sluzhba (её условие — «при входе в систему»), +# и робот поднимается сам, без человека. +# +# ЦЕНА. Пароль ляжет в реестр в открытом виде. Читать его может только +# администратор — то есть тот, кто и так может на этой машине всё. Опасность +# настоящая одна: копия диска (снимок виртуальной машины) в чужих руках. +# +# ЗАПУСК (в окне PowerShell «от имени администратора»): +# powershell -ExecutionPolicy Bypass -File C:\liderra\mts-telegram-robot\bin\nastroit-avtovhod.ps1 +# +# ОТМЕНА (вернуть как было): +# powershell -ExecutionPolicy Bypass -File C:\liderra\mts-telegram-robot\bin\nastroit-avtovhod.ps1 -Otmenit + +param([switch]$Otmenit) + +$ErrorActionPreference = 'Stop' +$klyuch = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +$polzovatel = 'Administrator' + +function Est-LiPravaAdmina { + $ya = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + return $ya.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Est-LiPravaAdmina)) { + Write-Host 'Нужны права администратора. Откройте PowerShell «от имени администратора» и запустите ещё раз.' -ForegroundColor Red + exit 1 +} + +if ($Otmenit) { + foreach ($imya in 'AutoAdminLogon', 'DefaultPassword', 'DefaultUserName', 'DefaultDomainName') { + Remove-ItemProperty -Path $klyuch -Name $imya -ErrorAction SilentlyContinue + } + Write-Host 'Автоматический вход выключен. После перезагрузки система снова спросит пароль,' -ForegroundColor Yellow + Write-Host 'и робот не поднимется, пока в систему не войдут руками.' -ForegroundColor Yellow + exit 0 +} + +Write-Host '' +Write-Host "Пароль учётной записи $polzovatel на этой машине." -ForegroundColor Cyan +Write-Host 'Он никуда не отправляется — остаётся на этом компьютере.' -ForegroundColor DarkGray +$tayna = Read-Host -AsSecureString 'Пароль' + +$otkrytyy = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($tayna) +) + +if ([string]::IsNullOrEmpty($otkrytyy)) { + Write-Host 'Пустой пароль. Ничего не менял.' -ForegroundColor Red + exit 1 +} + +# 🔑 ДАТЧИК. Проверяем пароль ДО записи. Иначе неверный пароль обнаружится только +# при следующей перезагрузке — то есть тогда, когда чинить будет уже некому. +Add-Type -AssemblyName System.DirectoryServices.AccountManagement +$kontekst = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('Machine', $env:COMPUTERNAME) +$verno = $kontekst.ValidateCredentials($polzovatel, $otkrytyy) + +if (-not $verno) { + Write-Host '' + Write-Host 'Пароль НЕ подошёл. Ничего не менял — настройки остались прежними.' -ForegroundColor Red + exit 1 +} + +Write-Host 'Пароль проверен — подходит.' -ForegroundColor Green + +Set-ItemProperty -Path $klyuch -Name 'AutoAdminLogon' -Value '1' -Type String +Set-ItemProperty -Path $klyuch -Name 'DefaultUserName' -Value $polzovatel -Type String +Set-ItemProperty -Path $klyuch -Name 'DefaultDomainName' -Value $env:COMPUTERNAME -Type String +Set-ItemProperty -Path $klyuch -Name 'DefaultPassword' -Value $otkrytyy -Type String + +Write-Host '' +Write-Host 'Готово. Теперь при загрузке система входит в сеанс сама,' -ForegroundColor Green +Write-Host 'а задача Liderra-MTS-Sluzhba поднимает робота как обычно.' -ForegroundColor Green +Write-Host '' +Write-Host 'Проверить можно только настоящей перезагрузкой: перезагрузите машину,' -ForegroundColor Cyan +Write-Host 'НЕ входите руками, подождите три минуты и посмотрите журнал робота:' -ForegroundColor Cyan +Write-Host ' Get-Content C:\liderra\mts-telegram-robot\sluzhba.log -Encoding UTF8 -Tail 5' -ForegroundColor Cyan +Write-Host 'Свежие строки «poll :: код 0» значат, что робот поднялся сам.' -ForegroundColor Cyan diff --git a/bots/mts-telegram-ads/bin/poll.js.bak-20260801 b/bots/mts-telegram-ads/bin/poll.js.bak-20260801 new file mode 100644 index 00000000..0fc29057 --- /dev/null +++ b/bots/mts-telegram-ads/bin/poll.js.bak-20260801 @@ -0,0 +1,67 @@ +import 'dotenv/config'; +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config.js'; +import { parseTask } from '../src/task.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; +import { runTask } from '../src/runner.js'; +import { portalClient } from '../src/portal.js'; + +// Один проход: спросить портал, есть ли работа; если есть — сделать и отчитаться. +// Запускается таймером раз в минуту. Своей петли внутри НЕТ — так проще убить и +// перезапустить, и один зависший проход не мешает следующему (портал вернёт задание +// в очередь по сроку аренды). + +const baseUrl = process.env.PORTAL_BASE_URL; +const token = process.env.TG_ROBOT_TOKEN; +if (!baseUrl || !token) { + console.error('Не заданы PORTAL_BASE_URL и/или TG_ROBOT_TOKEN'); + process.exit(2); +} + +// Сама проверка токена живёт в portalClient — здесь только переводим её в понятное +// сообщение и код выхода 2 вместо стопки Node. +let portal; +try { + portal = portalClient({ baseUrl, token }); +} catch (e) { + console.error(e.message); + process.exit(2); +} + + +const job = await portal.next(); + +if (!job) { + console.log('Работы нет.'); + process.exit(0); +} + +console.log(`Задание ${job.id}, режим ${job.mode}`); + +const config = loadConfig(); +const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + +// Номера — ПДн: кладём во временный файл и убираем в finally при любом исходе. +const phonesFile = join(tmpdir(), `tg-phones-${job.id}-${process.pid}.txt`); +let result; + +try { + const phones = await portal.phones(job.phones_url); + writeFileSync(phonesFile, phones.join('\n'), 'utf8'); + + const task = parseTask({ ...job.task, mode: job.mode, phonesFile }); + result = await runTask(config, task, mailer, { timestamp }); +} catch (e) { + result = { ok: false, reason: String(e.message).slice(0, 255), step: 'poll' }; +} finally { + if (existsSync(phonesFile)) { + try { unlinkSync(phonesFile); } catch { /* файл уже убран */ } + } +} + +await portal.done(job.id, result); +console.log(`Отчитались по заданию ${job.id}: ok=${result.ok}`); +process.exit(result.ok ? 0 : 1); diff --git a/bots/mts-telegram-ads/bin/sluzhba-tiho.vbs b/bots/mts-telegram-ads/bin/sluzhba-tiho.vbs new file mode 100644 index 00000000..b71c3531 --- /dev/null +++ b/bots/mts-telegram-ads/bin/sluzhba-tiho.vbs @@ -0,0 +1,15 @@ +' Zapusk sluzhby robota BEZ chernogo okna. +' +' Pochemu VBS, a ne pryamoy zapusk node iz Planirovshchika: +' Zadacha s tipom vhoda Interactive podnimaet okno konsoli v seanse vladeltsa - +' te samye chernye okna, ot kotoryh uhodili (kvirk #114). WScript.Shell.Run s +' parametrom 0 zapuskaet process skrytym. +' +' Tretiy parametr True - zhdat okonchaniya. Eto narochno: poka node zhivet, zhivet +' i sama zadacha, poetomu Planirovshchik vidit padenie i perezapuskaet sluzhbu. +' S False zadacha zavershilas by mgnovenno i schitalas by uspeshnoy vsegda. + +Dim sh +Set sh = CreateObject("WScript.Shell") +sh.CurrentDirectory = "C:\liderra\mts-telegram-robot" +sh.Run """C:\liderra\node20\node.exe"" bin\sluzhba.js", 0, True diff --git a/bots/mts-telegram-ads/bin/sluzhba.js b/bots/mts-telegram-ads/bin/sluzhba.js new file mode 100644 index 00000000..1e1da8c7 --- /dev/null +++ b/bots/mts-telegram-ads/bin/sluzhba.js @@ -0,0 +1,180 @@ +import { spawn, execFile } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sleduyushchiyShag, reshenieZamka, etoNashProfil, MINUTA } from '../src/sluzhba-plan.js'; + +// Вечная петля робота: раз в минуту спрашивает портал о работе, раз в час проверяет +// вход в кабинет. Заменяет собой ДВЕ задачи Планировщика. +// +// 🔴 Зачем ушли с Планировщика. Обе задачи ходили с типом входа S4U — «вход без +// пароля». У такого входа нет ключей, которыми браузер расшифровывает сохранённые +// печенья профиля, поэтому КАЖДЫЙ заход видел мёртвый кабинет: с 04.08 20:01 по +// 06.08 сторож дал 71 отказ подряд, включая глухую ночь. Замерено 06.08: заход +// Планировщика в 12:24 сказал «вход потерян», а три захода руками в 13:3x прошли +// нормально и никто заново не входил. Значит S4U вход не ломает — он его не видит. +// Служба живёт внутри сеанса владельца, где профиль читается. +// +// Петля намеренно тупая: сама ничего не решает, только исполняет ответ +// `sleduyushchiyShag` и пишет в журнал. Вся арифметика — в src/sluzhba-plan.js +// под тестами, потому что вечный цикл со сном тестом не поймать. + +const papka = join(dirname(fileURLToPath(import.meta.url)), '..'); +const zhurnal = join(papka, 'sluzhba.log'); +const profil = 'C:\\liderra\\mts-bot-profile'; +const SHAG = 5_000; + +let idyot = false; +let poslednijPoll = null; +let poslednijKeepalive = null; +let poslednyayaZhaloba = null; +let ostanovka = false; + +function zapishi(stroka) { + const vremya = new Date().toISOString().replace('T', ' ').slice(0, 19); + try { + appendFileSync(zhurnal, `${vremya} ${stroka}\n`, 'utf8'); + } catch { + // Журнал недоступен — служба всё равно обязана работать. Это единственное место, + // где молчание оправдано: писать о невозможности писать некуда. + } + console.log(stroka); +} + +/** + * Держит ли профиль браузера кто-то ещё. + * + * 🔴 Профиль ОДИН на робота, сторожа и окно ручного входа владельца. Пока владелец + * входит руками, лезть в кабинет нельзя: либо браузер не поднимется, либо помешаем + * владельцу, а сторож пришлёт ЛОЖНОЕ письмо «вход слетел». + * + * 🪤 Сравнение путей вынесено в `etoNashProfil` под тесты. Раньше сравнивали буква в + * букву прямо тут, в строке PowerShell, — и защита не срабатывала НИ РАЗУ: браузер + * подписывается путём с ПРЯМЫМИ косыми чертами (так он записан в настройках), а искали + * обратные. Владелец из-за этого не мог войти в кабинет руками — служба поднимала свой + * браузер поверх его окна, и окно схлопывалось. Поймано 06.08.2026 с его слов. + * + * Поэтому PowerShell теперь только ПЕРЕЧИСЛЯЕТ командные строки, а решает — наш это + * профиль или чужой — обычный код, у которого есть сторожа. + */ +function profilZanyat() { + return new Promise((resolve) => { + execFile( + 'powershell', + [ + '-NoProfile', '-NonInteractive', '-Command', + `Get-CimInstance Win32_Process -Filter "Name='msedge.exe' OR Name='chrome.exe'" | ForEach-Object { $_.CommandLine }`, + ], + { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + (e, stdout) => { + // Не смогли спросить — считаем, что свободен: иначе одна заминка среды + // усыпила бы службу навсегда, а это ровно та беда, от которой уходим. + if (e) return resolve(false); + resolve(String(stdout).split(/\r?\n/).some((stroka) => etoNashProfil(stroka, profil))); + }, + ); + }); +} + +/** Один заход: запускаем скрипт отдельным процессом и ждём его конца. */ +function zapusti(skript) { + return new Promise((resolve) => { + const p = spawn(process.execPath, [join(papka, 'bin', skript)], { cwd: papka }); + let hvost = ''; + const sobiray = (d) => { hvost = (hvost + d).slice(-500); }; + p.stdout.on('data', sobiray); + p.stderr.on('data', sobiray); + // Процесс не запустился вовсе — это не повод ронять службу. + p.on('error', (e) => resolve({ kod: -1, hvost: e.message })); + p.on('close', (kod) => resolve({ kod, hvost: hvost.replace(/\s+/g, ' ').trim() })); + }); +} + +async function shag() { + const seychas = Date.now(); + const chto = sleduyushchiyShag({ idyot, poslednijPoll, poslednijKeepalive, seychas }); + if (chto === 'zhdat') return; + + if (await profilZanyat()) { + // Жалуемся не чаще раза в минуту: иначе журнал забьётся, пока открыто окно входа. + if (poslednyayaZhaloba === null || seychas - poslednyayaZhaloba >= MINUTA) { + poslednyayaZhaloba = seychas; + zapishi('ПРОПУСК: профиль занят — либо робот везёт кампанию, либо открыт вход руками'); + } + return; + } + + idyot = true; + try { + const skript = chto === 'keepalive' ? 'keepalive.js' : 'poll.js'; + const { kod, hvost } = await zapusti(skript); + if (chto === 'keepalive') poslednijKeepalive = Date.now(); + else poslednijPoll = Date.now(); + zapishi(`${chto} :: код ${kod} :: ${hvost}`); + } catch (e) { + // Любая неожиданность — в журнал, но петля живёт дальше. Служба, умершая молча, + // ничем не лучше задачи Планировщика, которая молча не могла работать. + zapishi(`${chto} :: СБОЙ САМОЙ СЛУЖБЫ :: ${e?.message ?? e}`); + } finally { + idyot = false; + } +} + +/** Жив ли процесс с таким номером. Сигнал 0 ничего не шлёт — только спрашивает. */ +function processZhiv(nomer) { + try { + process.kill(nomer, 0); + return true; + } catch { + return false; + } +} + +const zamok = join(papka, 'sluzhba.lock'); + +function zanyatZamok() { + let zapis = null; + try { + zapis = readFileSync(zamok, 'utf8'); + } catch { + // Файла нет — путь свободен, это обычное дело при первом запуске. + } + + const nomer = Number(String(zapis ?? '').trim()); + const reshenie = reshenieZamka({ zapis, zhiv: Number.isInteger(nomer) && nomer > 0 && processZhiv(nomer) }); + + if (reshenie === 'zanyat') { + zapishi(`НЕ ЗАПУСКАЮСЬ: служба уже работает, номер процесса ${nomer}`); + return false; + } + if (reshenie === 'perehvat') { + zapishi(`замок остался от мёртвого процесса ${String(zapis).trim()} — перехватываю`); + } + + writeFileSync(zamok, String(process.pid), 'utf8'); + return true; +} + +function otdayZamok() { + try { + // Снимаем только СВОЙ замок: чужой мог занять перехвативший нас экземпляр. + if (readFileSync(zamok, 'utf8').trim() === String(process.pid)) unlinkSync(zamok); + } catch { + // Замка уже нет — ровно то состояние, которого добивались. + } +} + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) { + process.on(signal, () => { ostanovka = true; zapishi(`остановка по ${signal}`); }); +} + +if (!zanyatZamok()) process.exit(0); +process.on('exit', otdayZamok); + +zapishi('служба поднялась'); +while (!ostanovka) { + await shag(); + await new Promise((r) => setTimeout(r, SHAG)); +} +otdayZamok(); +zapishi('служба остановлена'); diff --git a/bots/mts-telegram-ads/bin/sluzhba.js.bak-20260806 b/bots/mts-telegram-ads/bin/sluzhba.js.bak-20260806 new file mode 100644 index 00000000..456f4eb4 --- /dev/null +++ b/bots/mts-telegram-ads/bin/sluzhba.js.bak-20260806 @@ -0,0 +1,171 @@ +import { spawn, execFile } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sleduyushchiyShag, reshenieZamka, MINUTA } from '../src/sluzhba-plan.js'; + +// Вечная петля робота: раз в минуту спрашивает портал о работе, раз в час проверяет +// вход в кабинет. Заменяет собой ДВЕ задачи Планировщика. +// +// 🔴 Зачем ушли с Планировщика. Обе задачи ходили с типом входа S4U — «вход без +// пароля». У такого входа нет ключей, которыми браузер расшифровывает сохранённые +// печенья профиля, поэтому КАЖДЫЙ заход видел мёртвый кабинет: с 04.08 20:01 по +// 06.08 сторож дал 71 отказ подряд, включая глухую ночь. Замерено 06.08: заход +// Планировщика в 12:24 сказал «вход потерян», а три захода руками в 13:3x прошли +// нормально и никто заново не входил. Значит S4U вход не ломает — он его не видит. +// Служба живёт внутри сеанса владельца, где профиль читается. +// +// Петля намеренно тупая: сама ничего не решает, только исполняет ответ +// `sleduyushchiyShag` и пишет в журнал. Вся арифметика — в src/sluzhba-plan.js +// под тестами, потому что вечный цикл со сном тестом не поймать. + +const papka = join(dirname(fileURLToPath(import.meta.url)), '..'); +const zhurnal = join(papka, 'sluzhba.log'); +const profil = 'C:\\liderra\\mts-bot-profile'; +const SHAG = 5_000; + +let idyot = false; +let poslednijPoll = null; +let poslednijKeepalive = null; +let poslednyayaZhaloba = null; +let ostanovka = false; + +function zapishi(stroka) { + const vremya = new Date().toISOString().replace('T', ' ').slice(0, 19); + try { + appendFileSync(zhurnal, `${vremya} ${stroka}\n`, 'utf8'); + } catch { + // Журнал недоступен — служба всё равно обязана работать. Это единственное место, + // где молчание оправдано: писать о невозможности писать некуда. + } + console.log(stroka); +} + +/** + * Держит ли профиль браузера кто-то ещё. + * + * 🔴 Профиль ОДИН на робота, сторожа и окно ручного входа владельца. Пока владелец + * входит руками, лезть в кабинет нельзя: либо браузер не поднимется, либо помешаем + * владельцу, а сторож пришлёт ЛОЖНОЕ письмо «вход слетел». + */ +function profilZanyat() { + return new Promise((resolve) => { + execFile( + 'powershell', + [ + '-NoProfile', '-NonInteractive', '-Command', + `@(Get-CimInstance Win32_Process -Filter "Name='msedge.exe' OR Name='chrome.exe'" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${profil}') }).Count`, + ], + { timeout: 30_000 }, + (e, stdout) => { + // Не смогли спросить — считаем, что свободен: иначе одна заминка среды + // усыпила бы службу навсегда, а это ровно та беда, от которой уходим. + if (e) return resolve(false); + resolve(Number(String(stdout).trim()) > 0); + }, + ); + }); +} + +/** Один заход: запускаем скрипт отдельным процессом и ждём его конца. */ +function zapusti(skript) { + return new Promise((resolve) => { + const p = spawn(process.execPath, [join(papka, 'bin', skript)], { cwd: papka }); + let hvost = ''; + const sobiray = (d) => { hvost = (hvost + d).slice(-500); }; + p.stdout.on('data', sobiray); + p.stderr.on('data', sobiray); + // Процесс не запустился вовсе — это не повод ронять службу. + p.on('error', (e) => resolve({ kod: -1, hvost: e.message })); + p.on('close', (kod) => resolve({ kod, hvost: hvost.replace(/\s+/g, ' ').trim() })); + }); +} + +async function shag() { + const seychas = Date.now(); + const chto = sleduyushchiyShag({ idyot, poslednijPoll, poslednijKeepalive, seychas }); + if (chto === 'zhdat') return; + + if (await profilZanyat()) { + // Жалуемся не чаще раза в минуту: иначе журнал забьётся, пока открыто окно входа. + if (poslednyayaZhaloba === null || seychas - poslednyayaZhaloba >= MINUTA) { + poslednyayaZhaloba = seychas; + zapishi('ПРОПУСК: профиль занят — либо робот везёт кампанию, либо открыт вход руками'); + } + return; + } + + idyot = true; + try { + const skript = chto === 'keepalive' ? 'keepalive.js' : 'poll.js'; + const { kod, hvost } = await zapusti(skript); + if (chto === 'keepalive') poslednijKeepalive = Date.now(); + else poslednijPoll = Date.now(); + zapishi(`${chto} :: код ${kod} :: ${hvost}`); + } catch (e) { + // Любая неожиданность — в журнал, но петля живёт дальше. Служба, умершая молча, + // ничем не лучше задачи Планировщика, которая молча не могла работать. + zapishi(`${chto} :: СБОЙ САМОЙ СЛУЖБЫ :: ${e?.message ?? e}`); + } finally { + idyot = false; + } +} + +/** Жив ли процесс с таким номером. Сигнал 0 ничего не шлёт — только спрашивает. */ +function processZhiv(nomer) { + try { + process.kill(nomer, 0); + return true; + } catch { + return false; + } +} + +const zamok = join(papka, 'sluzhba.lock'); + +function zanyatZamok() { + let zapis = null; + try { + zapis = readFileSync(zamok, 'utf8'); + } catch { + // Файла нет — путь свободен, это обычное дело при первом запуске. + } + + const nomer = Number(String(zapis ?? '').trim()); + const reshenie = reshenieZamka({ zapis, zhiv: Number.isInteger(nomer) && nomer > 0 && processZhiv(nomer) }); + + if (reshenie === 'zanyat') { + zapishi(`НЕ ЗАПУСКАЮСЬ: служба уже работает, номер процесса ${nomer}`); + return false; + } + if (reshenie === 'perehvat') { + zapishi(`замок остался от мёртвого процесса ${String(zapis).trim()} — перехватываю`); + } + + writeFileSync(zamok, String(process.pid), 'utf8'); + return true; +} + +function otdayZamok() { + try { + // Снимаем только СВОЙ замок: чужой мог занять перехвативший нас экземпляр. + if (readFileSync(zamok, 'utf8').trim() === String(process.pid)) unlinkSync(zamok); + } catch { + // Замка уже нет — ровно то состояние, которого добивались. + } +} + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) { + process.on(signal, () => { ostanovka = true; zapishi(`остановка по ${signal}`); }); +} + +if (!zanyatZamok()) process.exit(0); +process.on('exit', otdayZamok); + +zapishi('служба поднялась'); +while (!ostanovka) { + await shag(); + await new Promise((r) => setTimeout(r, SHAG)); +} +otdayZamok(); +zapishi('служба остановлена'); diff --git a/bots/mts-telegram-ads/src/browser.js b/bots/mts-telegram-ads/src/browser.js index aeb59bf4..c3a3680e 100644 --- a/bots/mts-telegram-ads/src/browser.js +++ b/bots/mts-telegram-ads/src/browser.js @@ -1,9 +1,6 @@ import { chromium } from 'playwright'; -// extraArgs — добавочные ключи запуска браузера. Нужны, чтобы держать ОДНО окно -// открытым между вызовами (`--remote-debugging-port`), а не поднимать браузер заново -// на каждый шаг: кабинет МТС — тяжёлый SPA, и каждое открытие стоит минуты. -export async function openBrowser(config, { headless = false, extraArgs = [] } = {}) { +export async function openBrowser(config, { headless = false } = {}) { // Анти-бот МТС (Qrator) режет «голый» Chromium (Playwright) — отдаёт 403, хотя // обычный Chrome/Яндекс с этой же машины кабинет пускает. Поэтому: // 1) channel:'chrome' — управляем НАСТОЯЩИМ установленным Google Chrome @@ -23,7 +20,7 @@ export async function openBrowser(config, { headless = false, extraArgs = [] } = viewport: { width: 1440, height: 900 }, locale: 'ru-RU', ignoreDefaultArgs: ['--enable-automation'], - args: [...stealthArgs, ...extraArgs], + args: [...stealthArgs], }; if (config.proxy) { // Выход через сервер конкурентного поля (185.182.110.115): локальный SOCKS от diff --git a/bots/mts-telegram-ads/src/cabinet.js b/bots/mts-telegram-ads/src/cabinet.js index ad7df871..2610874a 100644 --- a/bots/mts-telegram-ads/src/cabinet.js +++ b/bots/mts-telegram-ads/src/cabinet.js @@ -25,45 +25,6 @@ export function parseCost(text) { return `${intPart}.${frac}`; } -// Чистая утилита П‑7: разбирает СТРОКУ СПИСКА кампаний на метрики показа. -// На вход — куски текста ряда по порядку (`kuski`), на выход -// { impressions, clicks, subscribers, spendRub }, где каждое значение — число -// либо null («данных нет»). -// -// 🔑 Считаем С КОНЦА. Начало ряда плавает: у запущенной кампании два действия -// («Завершить», «Посмотреть настройки»), у черновика одно, у отклонённой три -// (+«Причины»). Хвост же постоянен — последние 12 кусков это ровно 12 колонок -// метрик в порядке шапки: Показы · Клики · CTR · CPC · Подписчики · CR · CPF · -// Расход в Telegram · Отправлено SMS · Цена SMS МТС · Расход в SMS · Общий расход -// (снято живьём 07.08.2026, docs/cabinet-flow.md §8a). -// -// 🔴 Прочерк «—» — это «не знаем», а НЕ ноль. Ноль показов и отсутствие данных -// клиенту обязаны выглядеть по-разному (граница честности спеки §3.1). -// -// Расход берём «в Telegram» — это НАШ закупочный расход у площадки. Клиенту его -// показывать нельзя (та же утечка, что закрыта $hidden на mts_cost_rub), он нужен -// нам для сверки. -const STATS_KOLONOK = 12; - -export function parseStatsRow(kuski) { - if (!Array.isArray(kuski) || kuski.length < STATS_KOLONOK) { - throw new Error(`В строке кампании меньше ${STATS_KOLONOK} колонок метрик — разбирать нечего`); - } - const hvost = kuski.slice(-STATS_KOLONOK); - const chislo = (t) => { - const s = String(t ?? '').replace(/ /g, ' ').trim(); - if (!/\d/.test(s)) return null; // «—», пусто, любой текст без цифр - const n = Number(s.replace(/[^\d,.]/g, '').replace(/ /g, '').replace(',', '.')); - return Number.isFinite(n) ? n : null; - }; - return { - impressions: chislo(hvost[0]), - clicks: chislo(hvost[1]), - subscribers: chislo(hvost[4]), - spendRub: chislo(hvost[7]), - }; -} - // Достаёт числовой id кампании МТС из URL кабинета // («.../telegram-a2p/2231134/message» → «2231134»). Нет совпадения → null. export function parseCampaignId(url) { @@ -78,7 +39,7 @@ export function parseCampaignId(url) { // Принимает как чистый статус, так и всю строку-блоб ряда (название + статус + // ссылки-действия), и приводит к канону для Laravel-опросчика (задача 3.4): // 'rejected' — «Отклонена»; -// 'approved' — «Одобрена» ИЛИ «Активна» (модерация пройдена, показы идут); +// 'approved' — «Одобрена» ИЛИ «Активна» ИЛИ «Запущена» (модерация пройдена, показы идут); // 'moderating'— «На модерации» (ещё проверяют); // 'draft' — «Черновик» (на модерацию ещё не уходила); // null — статус не распознан / пустой / не строка. @@ -93,15 +54,103 @@ export function parseModerationStatus(text) { const s = text.replace(/\s+/g, ' ').trim().toLowerCase(); if (s === '') return null; if (s.includes('отклонен')) return 'rejected'; - // 🔴 «Запущена» — так кабинет пишет у РАБОТАЮЩЕЙ кампании (живой замер 07.08.2026, - // кампания 2237821). Слова не знали: опросчик модерации на такой строке падал и - // держал кампанию «на проверке», хотя показы уже шли. + // 🔑 Живое одобрение 07.08.2026 — то самое, которого ждала вся приёмка. Кабинет + // написал седьмое слово: «Запущена». Робот его не знал и с 06:18 на каждом заходе + // отвечал «статус не определён», а портал держал кампанию на модерации с + // замороженными деньгами клиента. Снаружи это не отличалось от «вердикта ещё нет»: + // одобрение пришло, и мы бы о нём не узнали вовсе, не заглянув в кабинет глазами. if (s.includes('одобрен') || s.includes('активн') || s.includes('запущен')) return 'approved'; - if (s.includes('модераци')) return 'moderating'; + // 🔑 Живой прогон 06.08.2026 (кампания 2237821, первая оплаченная роботом): + // сразу после оплаты кабинет ставит «Запланирована» — кампания ждёт даты старта. + // Это НЕ вердикт: считаем «ещё ждём», деньги по факту списываем только на + // «Одобрена»/«Активна». Прежде это слово было незнакомым, и читалка сдавалась. + if (s.includes('модераци') || s.includes('запланирован')) return 'moderating'; if (s.includes('черновик')) return 'draft'; return null; } +/** + * Дата старта кампании из строки-блоба ряда списка → 'ГГГГ-ММ-ДД' или null. + * + * 🔴 Зачем. Портал не передаёт кабинету ни одной даты — их ставит кабинет своими + * умолчаниями, и живьём (2237821, 06.08.2026) старт оказался ЗАВТРАШНИМ, тогда как + * экран обещал клиенту показы «7 дней» с сегодня. Читаем дату оттуда, куда робот и + * так ходит раз в час: ни одного лишнего захода в кабинет. + * + * 🪤 В той же строке стоят деньги. Прими робот «12.05 ₽» за дату — клиенту показали + * бы 12 мая днём старта, и выглядело бы это правдой. Поэтому: день и месяц строго + * двумя цифрами, дата обязана быть возможной, и число перед знаком рубля датой не + * считается. + * + * 🪤 Года в строке нет. Берём тот из трёх соседних, при котором дата ближе всего к + * «сегодня»: в конце декабря «02.01» — это следующий год, а в начале января «28.12» — + * прошлый. Подстановка текущего года молча ошибалась бы на год дважды в год. + * + * Никогда не бросает: дата — вещь необязательная, вердикт и деньги важнее. + */ +export function parseStartDate(text, segodnya = new Date()) { + if (typeof text !== 'string') return null; + const s = text.replace(/ /g, ' '); + + const re = /(\d{2})\.(\d{2})(?:\.(\d{4}))?/g; + for (let m = re.exec(s); m !== null; m = re.exec(s)) { + const doStroki = s[m.index - 1] ?? ''; + const posle = s.slice(m.index + m[0].length); + // Обрывок большего числа — не дата. + if (/[\d.,]/.test(doStroki) || /^[\d,]/.test(posle)) continue; + // Число перед знаком рубля — деньги, а не дата. + if (/^\s*₽/.test(posle)) continue; + + const den = Number(m[1]); + const mes = Number(m[2]); + const godIzStroki = m[3] ? Number(m[3]) : null; + + const kandidaty = godIzStroki !== null + ? [godIzStroki] + : [segodnya.getFullYear() - 1, segodnya.getFullYear(), segodnya.getFullYear() + 1]; + + let luchshiy = null; + for (const god of kandidaty) { + const d = new Date(god, mes - 1, den); + // 31 февраля не бывает: Date молча переедет на март — ловим это сверкой. + if (d.getFullYear() !== god || d.getMonth() !== mes - 1 || d.getDate() !== den) continue; + const daleko = Math.abs(d.getTime() - segodnya.getTime()); + if (luchshiy === null || daleko < luchshiy.daleko) luchshiy = { d, daleko }; + } + if (luchshiy === null) continue; + + const dva = (n) => String(n).padStart(2, '0'); + return `${luchshiy.d.getFullYear()}-${dva(mes)}-${dva(den)}`; + } + + return null; +} + +/** + * Слова-статусы, по которым ряд кампании опознаётся ВНУТРИ страницы списка. + * + * 🪤 Держать в одном месте с parseModerationStatus: когда «Запланирована» добавили + * только в разбор, ряд перестал находиться вовсе — подъём от ссылки вверх искал + * предка с одним из этих слов, не находил и отдавал пустую строку. Разбор при этом + * был исправен, и починка выглядела сделанной, оставаясь мёртвой. + */ +export const STATUS_SLOVA_RE = /(отклонен|одобрен|активн|запущен|модераци|запланирован|черновик)/i; + +/** + * Статус кампании в списке не распознан — кабинет показал новое слово. + * + * 🔑 Отказ ВРЕМЕННЫЙ: незнакомое слово ловится следующим проходом, а прежде такой + * случай хоронил задание навсегда (retryable=false) и оставлял деньги клиента + * замороженными без единого шанса на разбор. + */ +export class StatusNotRecognizedError extends Error { + constructor(message) { + super(message); + this.name = 'StatusNotRecognizedError'; + this.retryable = true; + } +} + // Порог «не МТС» из UI кабинета (шаг «Аудитория»): ниже этого числа кнопка // «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md. export const MIN_NON_MTS = 367; @@ -266,32 +315,54 @@ export function datesForRestart(segodnya) { }; } -// Выставляет в списке кампаний срок статистики «За всё время» (П‑7). +// ─── П‑7: что реклама принесла (добавлено 07.08.2026, разрешено владельцем) ─── +// +// Чистая утилита: разбирает СТРОКУ СПИСКА кампаний на метрики показа. На вход — +// куски текста ряда по порядку, на выход { impressions, clicks, subscribers, +// spendRub }, где каждое значение — число либо null («данных нет»). +// +// 🔑 Считаем С КОНЦА. Начало ряда плавает: у запущенной кампании два действия +// («Завершить», «Посмотреть настройки»), у черновика одно, у отклонённой три +// (+«Причины»). Хвост постоянен — последние 12 кусков это ровно 12 колонок метрик +// в порядке шапки: Показы · Клики · CTR · CPC · Подписчики · CR · CPF · +// Расход в Telegram · Отправлено SMS · Цена SMS МТС · Расход в SMS · Общий расход +// (снято живьём 07.08.2026, docs/cabinet-flow.md §8a). +// +// 🔴 Прочерк «—» — это «не знаем», а НЕ ноль. Ноль показов и отсутствие данных +// клиенту обязаны выглядеть по-разному. +const STATS_KOLONOK = 12; + +export function parseStatsRow(kuski) { + if (!Array.isArray(kuski) || kuski.length < STATS_KOLONOK) { + throw new Error(`В строке кампании меньше ${STATS_KOLONOK} колонок метрик — разбирать нечего`); + } + const hvost = kuski.slice(-STATS_KOLONOK); + const chislo = (t) => { + const s = String(t ?? '').replace(/ /g, ' ').trim(); + if (!/\d/.test(s)) return null; // «—», пусто, любой текст без цифр + const n = Number(s.replace(/[^\d,.]/g, '').replace(/ /g, '').replace(',', '.')); + return Number.isFinite(n) ? n : null; + }; + return { + impressions: chislo(hvost[0]), + clicks: chislo(hvost[1]), + subscribers: chislo(hvost[4]), + spendRub: chislo(hvost[7]), + }; +} + +// Выставляет в списке кампаний срок статистики «За всё время». // // 🔴 Зачем. По умолчанию список показывает «Статистика за последние 7 дней», а // карточка кампании — вообще только сегодняшний день. Прочитать цифры, не тронув -// срок, значит принести КУСОК вместо итога и выдать его клиенту за всё, что он -// купил. Живьём (07.08.2026) выпадашка сроков даёт: Сегодня · Вчера · Эта неделя · -// Прошлая неделя · Последние 7 дней · Этот месяц · Прошлый месяц · Последние 30 -// дней · Этот год · **За всё время** · Указать период. +// срок, значит принести КУСОК вместо итога и выдать его клиенту за всё, что он купил. // -// 🪤 Поле НЕЛЬЗЯ искать по id. Кабинет собран на downshift, и тот нумерует поля -// заново при каждой перерисовке списка: на первом заходе это `downshift-0-input`, -// на втором — уже другое (поймано живьём 07.08.2026: вторая кампания подряд падала -// с «не нашли поле»). Цепляемся за СОДЕРЖИМОЕ поля — оно всегда название срока. -// Клик нативный: коуч-марк кабинета перехватывает мышиный. Возвращает true, если -// поле после выбора действительно показывает «За всё время». +// 🪤 Поле НЕЛЬЗЯ искать по id: кабинет собран на downshift, тот нумерует поля заново +// при каждой перерисовке (вторая кампания подряд падала с «не нашли поле»). +// Цепляемся за СОДЕРЖИМОЕ поля — там всегда название срока. const SROK_V_POLE = /(сегодня|вчера|недел|месяц|последние \d+ дн|за всё время|этот год|указать период)/i; export async function setStatsPeriodAllTime(page, config) { - const nayti = () => page.evaluateHandle((reSrc) => { - const re = new RegExp(reSrc, 'i'); - return [...document.querySelectorAll('input')] - .find((el) => re.test((el.value || '').replace(/ /g, ' '))) || null; - }, SROK_V_POLE.source); - - // 🪤 Фильтры дорисовываются ПОЗЖЕ таблицы: сразу после перехода поля ещё нет - // (поймано живьём — вторая кампания подряд падала с «не нашли поле»). Ждём. const znachenieSroka = async () => page.evaluate((reSrc) => { const re = new RegExp(reSrc, 'i'); const el = [...document.querySelectorAll('input')] @@ -300,9 +371,8 @@ export async function setStatsPeriodAllTime(page, config) { }, SROK_V_POLE.source); // 🪤 Кабинет ЧАСТО отдаёт список полупустым: меню нарисовано, таблицы и фильтров - // нет (живьём на втором заходе подряд: 0 полей ввода, тело 1195 знаков против - // 2528 у нормальной страницы). Проверка `gotoStable` этого не ловит — тело-то не - // пустое. Лечится ТОЛЬКО перезагрузкой, поэтому ждём с перезагрузками. + // нет (живьём: 0 полей ввода, тело 1195 знаков против 2528 у нормальной страницы). + // gotoStable этого не ловит — тело-то не пустое. Лечится только перезагрузкой. let uzhe = null; for (let i = 0; i < 12 && uzhe === null; i++) { uzhe = await znachenieSroka(); @@ -313,7 +383,11 @@ export async function setStatsPeriodAllTime(page, config) { if (uzhe === null) throw new Error('Не нашли поле выбора срока статистики в списке кампаний'); if (/за всё время/i.test(uzhe)) return true; - const pole = await nayti(); + const pole = await page.evaluateHandle((reSrc) => { + const re = new RegExp(reSrc, 'i'); + return [...document.querySelectorAll('input')] + .find((el) => re.test((el.value || '').replace(/ /g, ' '))) || null; + }, SROK_V_POLE.source); await pole.evaluate((el) => { el.click(); el.focus(); }); await humanPause(config); @@ -327,15 +401,12 @@ export async function setStatsPeriodAllTime(page, config) { }); if (!vybrano) throw new Error('В списке сроков статистики нет варианта «За всё время»'); - // Таблица перерисовывается после смены срока — даём ей время и убеждаемся, - // что поле действительно переключилось (иначе прочитаем старые цифры). await page.waitForTimeout(2500); return /за всё время/i.test((await znachenieSroka()) || ''); } -// Открывает список кампаний, выставляет срок «За всё время» и читает метрики -// показа кампании по её id (П‑7). Возвращает -// { status, impressions, clicks, subscribers, spendRub } — числа либо null. +// Открывает список, ставит срок «За всё время» и читает метрики показа кампании. +// Возвращает { status, impressions, clicks, subscribers, spendRub } — числа либо null. // // Ряд находим не «до первого предка со статусом» (как readModerationStatus): у // запущенной кампании в ряду есть кнопка «Завершить», и она ловится статус-словом, @@ -405,7 +476,7 @@ export async function readCampaignStats(page, config, mtsCampaignId) { // детальной/визард-странице) → matcher расширен на оба (campaignHrefRe); (2) строка // списка — грид из div, контейнер со статусом ряда на ~8 уровней выше ссылки → подъём // поднят с 6 до 10 (возврат на ПЕРВОМ предке со статусом, выше склеиваются две кампании).> -export async function readModerationStatus(page, config, mtsCampaignId) { +export async function readModerationStatus(page, config, mtsCampaignId, segodnya = new Date()) { const id = String(mtsCampaignId); const alive = await gotoStable(page, config.telegramUrl, config); @@ -414,12 +485,14 @@ export async function readModerationStatus(page, config, mtsCampaignId) { // Текст ряда кампании: цепляемся за ссылку-действие с id в href, поднимаемся до // контейнера ряда, где есть слово-статус. Возвращаем его innerText (блоб ряда). - const rowText = await page.evaluate((cid) => { + const rowText = await page.evaluate(({ cid, statusSrc }) => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); const anchor = [...document.querySelectorAll('a[href]')] .find((a) => byId.test(a.getAttribute('href') || '')); - const STATUS = /(отклонен|одобрен|активн|модераци|черновик)/i; + // Слова-статусы приходят снаружи (STATUS_SLOVA_RE) — один список на разбор и на + // поиск ряда, иначе починка одного оставляет второе мёртвым. + const STATUS = new RegExp(statusSrc, 'i'); // Строка списка — грид из div (НЕ tr/li). Контейнер со статусом ряда — на ~8 // уровней выше ссылки (снято живьём A1 28.07.2026); поднимаемся до 10. Возвращаем // на ПЕРВОМ предке со статусом (тесный ряд): выше склеиваются две кампании. @@ -429,26 +502,32 @@ export async function readModerationStatus(page, config, mtsCampaignId) { if (STATUS.test(t)) return t; } return anchor ? norm(anchor.closest('tr,li,[role=row]')?.innerText || '') : null; - }, id); + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); const moderationStatus = parseModerationStatus(rowText); if (moderationStatus === null) { - throw new Error(`Статус кампании ${id} не определён в списке (ряд не найден или статус не распознан)`); + throw new StatusNotRecognizedError( + `Статус кампании ${id} не определён в списке (ряд не найден или статус не распознан)`, + ); } + // Дата старта — из ТОЙ ЖЕ строки, что и статус: лишних заходов в кабинет ноль. + // Не прочиталась → null, и это ничему не мешает: вердикт и деньги важнее даты. + const startDate = parseStartDate(rowText, segodnya); if (moderationStatus !== 'rejected') { - return { moderationStatus, reason: null }; + return { moderationStatus, reason: null, startDate }; } // Отклонена → причина из слайд-модалки. Клик по «Причины» в ряду кампании // нативным DOM-кликом (модалка-оверлей перехватывает мышиный клик). - const opened = await page.evaluate((cid) => { + const opened = await page.evaluate(({ cid, statusSrc }) => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); const anchor = [...document.querySelectorAll('a[href]')] .find((a) => byId.test(a.getAttribute('href') || '')); + const STATUS = new RegExp(statusSrc, 'i'); let row = anchor; for (let up = 0; up < 10 && row; up++, row = row.parentElement) { - if (/(отклонен|одобрен|активн|модераци|черновик)/i.test(norm(row.innerText))) break; + if (STATUS.test(norm(row.innerText))) break; } const scope = row || document; const link = [...scope.querySelectorAll('a,button,[role=button]')] @@ -456,7 +535,7 @@ export async function readModerationStatus(page, config, mtsCampaignId) { if (!link) return false; link.click(); return true; - }, id); + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); let reason = null; if (opened) { @@ -472,7 +551,7 @@ export async function readModerationStatus(page, config, mtsCampaignId) { }); } - return { moderationStatus, reason }; + return { moderationStatus, reason, startDate }; } // Вход в редактор ОТКЛОНЁННОЙ кампании через «Исправить» (robot-режим пересдачи, @@ -491,16 +570,17 @@ export async function openResubmitEditor(page, config, mtsCampaignId) { if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); await humanPause(config); - const clicked = await page.evaluate((cid) => { + const clicked = await page.evaluate(({ cid, statusSrc }) => { const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); const anchor = [...document.querySelectorAll('a[href]')] .find((a) => byId.test(a.getAttribute('href') || '')); if (!anchor) return 'ряд-не-найден'; // Поднимаемся до контейнера ряда (грид из div — как в readModerationStatus). + const STATUS = new RegExp(statusSrc, 'i'); let row = anchor; for (let up = 0; up < 10 && row; up++, row = row.parentElement) { - if (/(отклонен|одобрен|активн|модераци|черновик)/i.test(norm(row.innerText))) break; + if (STATUS.test(norm(row.innerText))) break; } const scope = row || document; const btn = [...scope.querySelectorAll('a,button,[role=button]')] @@ -508,7 +588,7 @@ export async function openResubmitEditor(page, config, mtsCampaignId) { if (!btn) return 'кнопка-Исправить-не-найдена'; btn.click(); return 'clicked'; - }, id); + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); if (clicked !== 'clicked') { throw new Error(`Пересдача: не удалось войти в «Исправить» кампании ${id} (${clicked})`); @@ -584,18 +664,60 @@ async function domClickButton(page, textRe) { }, { reSrc: textRe.source, forbidSrc: FORBIDDEN_BUTTON_RE.source }); } +// Окно-заслонка кабинета после «Продолжить» на шаге «Объявление»: «Отметили места, +// из-за которых рекламу могут отклонить — лучше сразу их поправить». Пока оно висит, +// шаг не переключается. +// +// 🔑 Снято живьём 06.08.2026 (кампания 2237812): робот его не знал, ждал перехода на +// «Стоимость» и падал невнятным «не перешли на /budget» — а деньги клиента при этом +// оставались замороженными. +const MODERATION_HINTS_RE = /из-за которых рекламу могут отклонить/i; + +/** Текст страницы — это окно замечаний модерации? */ +export function isModerationHintsText(text) { + return MODERATION_HINTS_RE.test(String(text || '')); +} + +/** + * Закрывает окно замечаний кнопкой «Пропустить». Возвращает true, если окно было. + * + * Замечания — совет кабинета, а не отказ: правит их человек, а не робот. Наше дело — + * не застрять. Вердикт всё равно вынесет модерация. + */ +export async function skipModerationHints(page, { + bodyText = (p) => p.locator('body').innerText(), + click = domClickButton, +} = {}) { + const text = await bodyText(page).catch(() => ''); + if (!isModerationHintsText(text)) return false; + + const res = await click(page, /^пропустить$/); + if (res === 'disabled') throw new Error('Кнопка «Пропустить» в окне замечаний модерации неактивна'); + if (res === null) throw new Error('Окно замечаний модерации показано, а кнопки «Пропустить» в нём нет'); + return true; +} + // Жмём «Продолжить» (обходя коуч-марк) и убеждаемся, что перешли на ожидаемый // URL. Иначе падаем громко с диагностикой — не заполняем «не ту» страницу. +// +// Между нажатием и переходом кабинет может показать окно замечаний модерации — +// его закрываем и ждём дальше, а не объявляем поломкой. async function continueTo(page, config, urlRe, stepName) { const res = await domClickButton(page, /^продолжить$/); if (res === 'disabled') throw new Error(`Кнопка «Продолжить» неактивна на шаге «${stepName}» — форма не заполнена/не готова`); if (res === null) throw new Error(`Кнопка «Продолжить» не найдена на шаге «${stepName}»`); - try { - await page.waitForURL(urlRe, { timeout: 30000 }); - } catch { - throw new Error(`После «Продолжить» не перешли на ${urlRe} (шаг «${stepName}»); текущий URL: ${page.url()}`); + + const dedlayn = Date.now() + 30000; + while (Date.now() < dedlayn) { + if (urlRe.test(page.url())) { + await humanPause(config); + return; + } + if (await skipModerationHints(page)) continue; + await page.waitForTimeout(1000); } - await humanPause(config); + + throw new Error(`После «Продолжить» не перешли на ${urlRe} (шаг «${stepName}»); текущий URL: ${page.url()}`); } // Шаг «Знакомство»: «я разбираюсь» + одна сфера бизнеса, затем «Продолжить». @@ -814,15 +936,9 @@ export async function selectOrdCategory(page, config, category) { // текст/ссылку/категорию ОРД — как ответ на причину отказа. null-поля не трогаем // (у кампании они уже заполнены). Документ модератору грузится отдельно // (attachModeratorDoc). Переиспользует те же проверенные хелперы, что и fillAd. -// 🔴 07.08.2026 (П‑9 спеки «клиент перестаёт быть слепым»): к правкам добавлены ЗАГОЛОВОК -// и МЕДИА. Проверка ругается на картинку и на заголовок не реже, чем на текст, а поправить -// их при пересдаче было физически нечем — клиент получал тот же отказ второй раз подряд. -// Обе функции проверены живьём на создании кампании; здесь тот же редактор «Сообщение». export async function editResubmitFields(page, config, task) { if (task.adText) await fillAdText(page, config, task.adText); - if (task.adHeadline) await fillAdHeadline(page, config, task.adHeadline); if (task.buttonUrl) await fillAdLink(page, config, task.buttonUrl); - if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile); if (task.ordCategory) await selectOrdCategory(page, config, task.ordCategory); } diff --git a/bots/mts-telegram-ads/src/cabinet.js.bak-20260807 b/bots/mts-telegram-ads/src/cabinet.js.bak-20260807 new file mode 100644 index 00000000..2653f0e8 --- /dev/null +++ b/bots/mts-telegram-ads/src/cabinet.js.bak-20260807 @@ -0,0 +1,1092 @@ +import { humanPause, gotoStable } from './browser.js'; +import { debugShotsEnabled } from './shots.js'; + +// Чистая утилита: вытаскивает денежную сумму из текста кабинета вида +// «Стоимость кампании от 201,6 ₽» и нормализует её в строку-десятичную дробь с +// двумя знаками после точки («201.60»). Запятая→точка, разделители тысяч (в т.ч. +// НЕРАЗРЫВНЫЙ пробел, код 160) убираются, дробная часть дополняется/обрезается до +// 2 знаков БЕЗ округления. Нет числа / пустой / null / undefined → null. +// +// Сознательно вынесена ОТДЕЛЬНОЙ чистой функцией (без DOM/page), покрытой +// юнит-тестами: её будет вызывать будущий шаг чтения фактической стоимости из +// кабинета (Этап 3 / живой прогон), когда селектор строки стоимости подтвердят +// живьём. К DOM-потоку (submitBudget/finalize) НЕ подключена намеренно. +export function parseCost(text) { + if (typeof text !== 'string') return null; + // nbsp (код 160) → обычный пробел, чтобы разделители тысяч ловились единообразно. + const cleaned = text.replace(/ /g, ' '); + // Первое число: цифры с внутренними пробелами-разделителями + опц. дробь через , или . + const m = cleaned.match(/\d[\d ]*(?:[.,]\d+)?/); + if (!m) return null; + const noSpace = m[0].replace(/ /g, ''); + const [intPart, fracRaw = ''] = noSpace.split(/[.,]/); + // Дробную часть строково дополняем/обрезаем до 2 знаков (без некорректного округления). + const frac = (fracRaw + '00').slice(0, 2); + return `${intPart}.${frac}`; +} + +// Достаёт числовой id кампании МТС из URL кабинета +// («.../telegram-a2p/2231134/message» → «2231134»). Нет совпадения → null. +export function parseCampaignId(url) { + if (typeof url !== 'string') return null; + const m = url.match(/telegram-a2p\/(\d+)/); + return m ? m[1] : null; +} + +// Чистый парсер статус-текста кабинета (задача 3.5). Статус кампании показан +// словом в списке /cabinet/campaigns/telegram (снято живьём — FLOW-FINDINGS.md +// «Сессия 6»): Черновик / На модерации / Отклонена / Одобрена / Активна. +// Принимает как чистый статус, так и всю строку-блоб ряда (название + статус + +// ссылки-действия), и приводит к канону для Laravel-опросчика (задача 3.4): +// 'rejected' — «Отклонена»; +// 'approved' — «Одобрена» ИЛИ «Активна» (модерация пройдена, показы идут); +// 'moderating'— «На модерации» (ещё проверяют); +// 'draft' — «Черновик» (на модерацию ещё не уходила); +// null — статус не распознан / пустой / не строка. +// Опросчик всё, что НЕ approved/rejected, трактует как «ждём» — draft/null безопасны. +// +// Порядок проверок: терминальные вердикты (rejected/approved) — ПЕРВЫМИ, чтобы в +// строке-блобе с прозой они побеждали слово «модераци…» (напр. «не прошла +// модерацию Отклонена» → rejected). +export function parseModerationStatus(text) { + if (typeof text !== 'string') return null; + // nbsp (код 160) → пробел; нижний регистр; схлопываем пробелы — сравнение по подстроке. + const s = text.replace(/\s+/g, ' ').trim().toLowerCase(); + if (s === '') return null; + if (s.includes('отклонен')) return 'rejected'; + if (s.includes('одобрен') || s.includes('активн')) return 'approved'; + // 🔑 Живой прогон 06.08.2026 (кампания 2237821, первая оплаченная роботом): + // сразу после оплаты кабинет ставит «Запланирована» — кампания ждёт даты старта. + // Это НЕ вердикт: считаем «ещё ждём», деньги по факту списываем только на + // «Одобрена»/«Активна». Прежде это слово было незнакомым, и читалка сдавалась. + if (s.includes('модераци') || s.includes('запланирован')) return 'moderating'; + if (s.includes('черновик')) return 'draft'; + return null; +} + +/** + * Дата старта кампании из строки-блоба ряда списка → 'ГГГГ-ММ-ДД' или null. + * + * 🔴 Зачем. Портал не передаёт кабинету ни одной даты — их ставит кабинет своими + * умолчаниями, и живьём (2237821, 06.08.2026) старт оказался ЗАВТРАШНИМ, тогда как + * экран обещал клиенту показы «7 дней» с сегодня. Читаем дату оттуда, куда робот и + * так ходит раз в час: ни одного лишнего захода в кабинет. + * + * 🪤 В той же строке стоят деньги. Прими робот «12.05 ₽» за дату — клиенту показали + * бы 12 мая днём старта, и выглядело бы это правдой. Поэтому: день и месяц строго + * двумя цифрами, дата обязана быть возможной, и число перед знаком рубля датой не + * считается. + * + * 🪤 Года в строке нет. Берём тот из трёх соседних, при котором дата ближе всего к + * «сегодня»: в конце декабря «02.01» — это следующий год, а в начале января «28.12» — + * прошлый. Подстановка текущего года молча ошибалась бы на год дважды в год. + * + * Никогда не бросает: дата — вещь необязательная, вердикт и деньги важнее. + */ +export function parseStartDate(text, segodnya = new Date()) { + if (typeof text !== 'string') return null; + const s = text.replace(/ /g, ' '); + + const re = /(\d{2})\.(\d{2})(?:\.(\d{4}))?/g; + for (let m = re.exec(s); m !== null; m = re.exec(s)) { + const doStroki = s[m.index - 1] ?? ''; + const posle = s.slice(m.index + m[0].length); + // Обрывок большего числа — не дата. + if (/[\d.,]/.test(doStroki) || /^[\d,]/.test(posle)) continue; + // Число перед знаком рубля — деньги, а не дата. + if (/^\s*₽/.test(posle)) continue; + + const den = Number(m[1]); + const mes = Number(m[2]); + const godIzStroki = m[3] ? Number(m[3]) : null; + + const kandidaty = godIzStroki !== null + ? [godIzStroki] + : [segodnya.getFullYear() - 1, segodnya.getFullYear(), segodnya.getFullYear() + 1]; + + let luchshiy = null; + for (const god of kandidaty) { + const d = new Date(god, mes - 1, den); + // 31 февраля не бывает: Date молча переедет на март — ловим это сверкой. + if (d.getFullYear() !== god || d.getMonth() !== mes - 1 || d.getDate() !== den) continue; + const daleko = Math.abs(d.getTime() - segodnya.getTime()); + if (luchshiy === null || daleko < luchshiy.daleko) luchshiy = { d, daleko }; + } + if (luchshiy === null) continue; + + const dva = (n) => String(n).padStart(2, '0'); + return `${luchshiy.d.getFullYear()}-${dva(mes)}-${dva(den)}`; + } + + return null; +} + +/** + * Слова-статусы, по которым ряд кампании опознаётся ВНУТРИ страницы списка. + * + * 🪤 Держать в одном месте с parseModerationStatus: когда «Запланирована» добавили + * только в разбор, ряд перестал находиться вовсе — подъём от ссылки вверх искал + * предка с одним из этих слов, не находил и отдавал пустую строку. Разбор при этом + * был исправен, и починка выглядела сделанной, оставаясь мёртвой. + */ +export const STATUS_SLOVA_RE = /(отклонен|одобрен|активн|модераци|запланирован|черновик)/i; + +/** + * Статус кампании в списке не распознан — кабинет показал новое слово. + * + * 🔑 Отказ ВРЕМЕННЫЙ: незнакомое слово ловится следующим проходом, а прежде такой + * случай хоронил задание навсегда (retryable=false) и оставлял деньги клиента + * замороженными без единого шанса на разбор. + */ +export class StatusNotRecognizedError extends Error { + constructor(message) { + super(message); + this.name = 'StatusNotRecognizedError'; + this.retryable = true; + } +} + +// Порог «не МТС» из UI кабинета (шаг «Аудитория»): ниже этого числа кнопка +// «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md. +export const MIN_NON_MTS = 367; + +// 🔴 Приёмка глазами 31.07.2026: когда рекламируется САЙТ (а не телеграм-канал), +// кабинет показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков) и +// «Текст на кнопке». Робот их не знал: «Продолжить» жалось, но страница молча +// оставалась на шаге «Объявление», а наверх уходило невнятное «не перешли на +// /budget». Падать надо СРАЗУ и своими словами — иначе причина не видна. +export function assertHeadlineWhenRequired(fieldPresent, headline) { + if (!fieldPresent) return; + if (typeof headline !== 'string' || headline.trim() === '') { + throw new Error('Кабинет требует заголовок объявления (реклама сайта), а он не задан'); + } +} + +// 🔴 Ревью-фикс F6: кнопки, которые ТРАТЯТ живые деньги клиента / запускают +// кампанию — робот НЕ жмёт их НИКОГДА (ни в draft, ни в live). Реальная отправка/ +// оплата на шаге /payment («Списать с баланса…») wire-ится в Сессии 6 под флаг. +// Основы («оплатит»/«списат»/«запуст») ловят словоформы, но НЕ «без оплаты» (0 ₽). +const FORBIDDEN_BUTTON_RE = /(оплатит|списат|запуст)/i; + +// Разрешённая кнопка продвижения на шаге подтверждения (точное совпадение, без +// денежных слов). На /confirmation это «ПРОДОЛЖИТЬ». +const SUBMIT_BUTTON_RE = /^(отправить на модерацию|продолжить)$/i; + +// Чистый предикат: текст кнопки — из чёрного списка (трата денег/запуск)? +export function isForbiddenButtonText(text) { + if (typeof text !== 'string') return false; + return FORBIDDEN_BUTTON_RE.test(text.replace(/\s+/g, ' ').trim()); +} + +// Кнопка БЕСПЛАТНОЙ отправки на модерацию (0 ₽) на шаге /payment: «Отправить на +// модерацию без оплаты» (подтверждено живьём A2, 28.07.2026 — баланс не тронут). +// Именно её целит submitWithoutPayment при пересдаче. Слово «оплаты» здесь НЕ +// денежная трата (см. FORBIDDEN_BUTTON_RE — основа «оплатит», а тут «без оплаты»). +const NO_PAYMENT_SUBMIT_RE = /отправить на модерацию без оплаты/i; + +// Чистый предикат: текст кнопки — «отправить на модерацию без оплаты»? +export function isNoPaymentSubmitText(text) { + if (typeof text !== 'string') return false; + return NO_PAYMENT_SUBMIT_RE.test(text.replace(/\s+/g, ' ').trim()); +} + +// 🔴 ПЛАТЯЩАЯ кнопка шага /payment: «Списать с баланса и отправить на модерацию». +// Она резервирует сумму на балансе кабинета МТС; окончательно спишут по факту +// показов, остаток вернётся (билинг-модель — FLOW-FINDINGS.md «Задача 2.0»). +// +// До 03.08.2026 её не жал никто и никогда: робот доводил кампанию до кассы и уходил +// (launched:false, stoppedAt:'payment'), а портал списывал смету с клиента ещё на +// запуске — клиент платил сто процентов ни за что, реклама не показывалась ни разу. +// Владелец разрешил жать «но с потолком» (03.08.2026). +// +// Разрешение ТОЧЕЧНОЕ: FORBIDDEN_BUTTON_RE не ослаблен — эта кнопка остаётся +// запретной для domClickButton и любого другого пути. Жмёт её один-единственный +// submitWithPayment, и только после того, как стоимость прочитана и прошла гейт. +const PAY_SUBMIT_RE = /списать с баланса и отправить на модерацию/i; + +// Чистый предикат: текст кнопки — та самая платящая? +export function isPaySubmitText(text) { + if (typeof text !== 'string') return false; + return PAY_SUBMIT_RE.test(text.replace(/\s+/g, ' ').trim()); +} + +// Потолок разовой траты = МЕНЬШИЙ из двух: лимит самой кампании (budget_cap_rub, +// приходит из портала в задании) и общий потолок робота (BUDGET_CAP_RUB из +// окружения). Любой из них не задан / не число / ≤ 0 → бросаем: «потолка нет» +// означало бы «плати сколько скажут», а это ровно то, чего владелец не разрешал. +export function payCapRub(taskBudgetRub, configCapRub) { + const nums = [taskBudgetRub, configCapRub].map((v) => Number(v)); + for (const n of nums) { + if (!Number.isFinite(n) || n <= 0) + throw new Error(`Потолок траты задан некорректно (лимит кампании ${taskBudgetRub}, общий ${configCapRub}) — оплата запрещена`); + } + return Math.min(nums[0], nums[1]); +} + +// Гейт оплаты: пускать клик по платящей кнопке или нет. +// +// 🔑 Отличие от assertCostWithinCap (F8): там нечитаемая стоимость — no-op, потому +// что реального списания на том шаге не было. Здесь списание НАСТОЯЩЕЕ, поэтому +// нечитаемая сумма — ЗАПРЕТ: не прочитали, сколько списываем, — не списываем. +export function assertPayable(costRub, capRub) { + const cost = Number(costRub); + if (costRub === null || costRub === undefined || costRub === '' || !Number.isFinite(cost)) + throw new Error(`Стоимость кампании на шаге оплаты не прочитана (${costRub}) — оплата запрещена`); + if (!Number.isFinite(capRub) || capRub <= 0) + throw new Error(`Потолок траты задан некорректно (${capRub}) — оплата запрещена`); + if (cost > capRub) + throw new Error(`Стоимость ${cost}₽ превышает потолок ${capRub}₽ — оплата запрещена`); +} + +// Ревью-фикс F8: сверка фактической стоимости кампании из кабинета с потолком. +// actualRub — строка из parseCost («201.60») или число; null/undefined/нечитаемо → +// сверять нечего (no-op; решение блокировать при нечитаемости — на вызывающем). +// Читаемая стоимость выше потолка → бросаем (боевой запуск запрещён). +export function assertCostWithinCap(actualRub, capRub) { + if (actualRub === null || actualRub === undefined) return; + const actual = Number(actualRub); + if (!Number.isFinite(actual)) return; + if (!Number.isFinite(capRub) || capRub <= 0) + throw new Error(`Потолок бюджета задан некорректно (${capRub}) — боевой запуск запрещён`); + if (actual > capRub) + throw new Error(`Фактическая стоимость ${actual}₽ превышает потолок ${capRub}₽ — боевой запуск запрещён`); +} + +// Матчит href ссылки на кампанию с её id. Живой прогон A1 (28.07.2026) показал: в +// СПИСКЕ кампаний ссылка = `/cabinet/campaigns/telegram/{id}`, а на детальной/визард- +// странице = `/…/telegram-a2p/{id}/…`. Ловим ОБА варианта; id как часть большего +// числа не считается (граница `\D`/конец). Regex-исходник переиспользуется внутри +// page.evaluate (readModerationStatus) — держать синхронным с этой функцией. +export function campaignHrefRe(id) { + return `telegram(?:-a2p)?/${id}(?:\\D|$)`; +} + +export function hrefMatchesCampaignId(href, id) { + if (typeof href !== 'string') return false; + return new RegExp(campaignHrefRe(id)).test(href); +} + +// Шаги мастера Telegram-кампании в порядке прохождения. Кабинет держит шаг в +// адресе: /cabinet/campaign/telegram-a2p/{id}/{шаг}. +export const WIZARD_STEPS = ['audience', 'message', 'budget', 'confirmation', 'payment']; + +// Какой шаг мастера открыт. Живой прогон 03.08.2026: «Исправить» у отклонённой +// кампании открывает НЕ «Сообщение», а «Стоимость» — предполагать шаг нельзя, его +// надо читать. 🪤 Сразу после клика адрес бывает голым (…/{id}/) — шага ещё нет, +// кнопок на странице тоже; такое считаем «шага нет» и ждём дальше. +export function wizardStepFromUrl(url) { + if (typeof url !== 'string') return null; + const m = url.match(/telegram-a2p\/\d+\/([a-z]+)(?:[?#]|$)/i); + const step = m ? m[1].toLowerCase() : null; + return WIZARD_STEPS.includes(step) ? step : null; +} + +// Кабинет отказывается пускать дальше со «Стоимости», если дата старта прошла. +// 🪤 Кнопка «Продолжить» при этом АКТИВНА и нажимается — просто ничего не +// происходит. Без этой проверки робот стоял бы на месте до тайм-аута и врал бы +// диагностикой «не перешли на /confirmation». +export function isPastStartDateMessage(text) { + return /измените дату начала/i.test(String(text || '')); +} + +// Новые даты для пересдачи: старт послезавтра (модерация успевает), конец через +// неделю после старта. Чистая функция — «сегодня» передаём снаружи. +// +// 🪤 odinMesyac: клик по клетке календаря выбирает день ПОКАЗАННОГО месяца. Если +// новые даты уезжают в следующий, кликать по номеру дня вслепую нельзя — выберем +// не тот день. Вызывающий обязан такой случай увидеть и не гадать. +export function datesForRestart(segodnya) { + const dvaDnya = new Date(segodnya.getFullYear(), segodnya.getMonth(), segodnya.getDate() + 2); + const nedelya = new Date(dvaDnya.getFullYear(), dvaDnya.getMonth(), dvaDnya.getDate() + 7); + const dd = (d) => String(d.getDate()).padStart(2, '0'); + const mm = (d) => String(d.getMonth() + 1).padStart(2, '0'); + const stroka = (d) => `${dd(d)}.${mm(d)}.${d.getFullYear()}`; + return { + startDen: dvaDnya.getDate(), + konecDen: nedelya.getDate(), + startStroka: stroka(dvaDnya), + konecStroka: stroka(nedelya), + odinMesyac: dvaDnya.getMonth() === segodnya.getMonth() && nedelya.getMonth() === segodnya.getMonth(), + }; +} + +// Открывает список кампаний и читает вердикт модерации кампании по её id +// (задача 3.5). Возвращает { moderationStatus, reason } — moderationStatus уже в +// каноне parseModerationStatus, reason (текст причины) заполняется только при +// 'rejected'. Статус НЕ распознан / ряд не найден → бросаем (опросчик оставит +// кампанию на модерации до следующего цикла). +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A1, 28.07.2026) на реальном отказе кампании 2231134: +// robot вернул {ok:true, moderationStatus:'rejected', reason:<полный текст 5 пунктов +// модерации>}. При этом живой прогон вскрыл и починил два бага локатора: (1) в СПИСКЕ +// ссылка = `/cabinet/campaigns/telegram/{id}`, а НЕ `/telegram-a2p/{id}` (тот — на +// детальной/визард-странице) → matcher расширен на оба (campaignHrefRe); (2) строка +// списка — грид из div, контейнер со статусом ряда на ~8 уровней выше ссылки → подъём +// поднят с 6 до 10 (возврат на ПЕРВОМ предке со статусом, выше склеиваются две кампании).> +export async function readModerationStatus(page, config, mtsCampaignId, segodnya = new Date()) { + const id = String(mtsCampaignId); + + const alive = await gotoStable(page, config.telegramUrl, config); + if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); + await humanPause(config); + + // Текст ряда кампании: цепляемся за ссылку-действие с id в href, поднимаемся до + // контейнера ряда, где есть слово-статус. Возвращаем его innerText (блоб ряда). + const rowText = await page.evaluate(({ cid, statusSrc }) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + // Слова-статусы приходят снаружи (STATUS_SLOVA_RE) — один список на разбор и на + // поиск ряда, иначе починка одного оставляет второе мёртвым. + const STATUS = new RegExp(statusSrc, 'i'); + // Строка списка — грид из div (НЕ tr/li). Контейнер со статусом ряда — на ~8 + // уровней выше ссылки (снято живьём A1 28.07.2026); поднимаемся до 10. Возвращаем + // на ПЕРВОМ предке со статусом (тесный ряд): выше склеиваются две кампании. + let el = anchor; + for (let up = 0; up < 10 && el; up++, el = el.parentElement) { + const t = norm(el.innerText); + if (STATUS.test(t)) return t; + } + return anchor ? norm(anchor.closest('tr,li,[role=row]')?.innerText || '') : null; + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); + + const moderationStatus = parseModerationStatus(rowText); + if (moderationStatus === null) { + throw new StatusNotRecognizedError( + `Статус кампании ${id} не определён в списке (ряд не найден или статус не распознан)`, + ); + } + // Дата старта — из ТОЙ ЖЕ строки, что и статус: лишних заходов в кабинет ноль. + // Не прочиталась → null, и это ничему не мешает: вердикт и деньги важнее даты. + const startDate = parseStartDate(rowText, segodnya); + if (moderationStatus !== 'rejected') { + return { moderationStatus, reason: null, startDate }; + } + + // Отклонена → причина из слайд-модалки. Клик по «Причины» в ряду кампании + // нативным DOM-кликом (модалка-оверлей перехватывает мышиный клик). + const opened = await page.evaluate(({ cid, statusSrc }) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + const STATUS = new RegExp(statusSrc, 'i'); + let row = anchor; + for (let up = 0; up < 10 && row; up++, row = row.parentElement) { + if (STATUS.test(norm(row.innerText))) break; + } + const scope = row || document; + const link = [...scope.querySelectorAll('a,button,[role=button]')] + .find((n) => /^причины$/i.test(norm(n.innerText))); + if (!link) return false; + link.click(); + return true; + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); + + let reason = null; + if (opened) { + await page.locator('#slide-modal-root').waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); + await humanPause(config); + reason = await page.evaluate(() => { + const root = document.querySelector('#slide-modal-root'); + const full = (root?.innerText || '').replace(/\r/g, '').trim(); + if (!full) return null; + // Отрезаем от заголовка «Причины отклонения кампании», если он есть. + const i = full.search(/Причины отклонения кампании/i); + return (i >= 0 ? full.slice(i) : full).trim().slice(0, 2000) || null; + }); + } + + return { moderationStatus, reason, startDate }; +} + +// Вход в редактор ОТКЛОНЁННОЙ кампании через «Исправить» (robot-режим пересдачи, +// R3). Открывает список, находит СТРОКУ кампании по её id (scope как в +// readModerationStatus — по campaignHrefRe), нативным кликом жмёт «Исправить» в этом +// ряду (модалка-оверлей перехватывает мышиный клик, нужен .click() по DOM-элементу), +// дожидается редактора на шаге «Сообщение» (/telegram-a2p/{id}/message). Ряд/кнопка +// не найдены или не тот URL → падаем громко. +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): вход «Исправить» кампании 2231134 открыл +// редактор сразу на /message с предзаполненными текстом/ссылкой/ОРД. +export async function openResubmitEditor(page, config, mtsCampaignId) { + const id = String(mtsCampaignId); + + const alive = await gotoStable(page, config.telegramUrl, config); + if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)'); + await humanPause(config); + + const clicked = await page.evaluate(({ cid, statusSrc }) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`); + const anchor = [...document.querySelectorAll('a[href]')] + .find((a) => byId.test(a.getAttribute('href') || '')); + if (!anchor) return 'ряд-не-найден'; + // Поднимаемся до контейнера ряда (грид из div — как в readModerationStatus). + const STATUS = new RegExp(statusSrc, 'i'); + let row = anchor; + for (let up = 0; up < 10 && row; up++, row = row.parentElement) { + if (STATUS.test(norm(row.innerText))) break; + } + const scope = row || document; + const btn = [...scope.querySelectorAll('a,button,[role=button]')] + .find((n) => /^исправить$/i.test(norm(n.innerText))); + if (!btn) return 'кнопка-Исправить-не-найдена'; + btn.click(); + return 'clicked'; + }, { cid: id, statusSrc: STATUS_SLOVA_RE.source }); + + if (clicked !== 'clicked') { + throw new Error(`Пересдача: не удалось войти в «Исправить» кампании ${id} (${clicked})`); + } + + // 🔴 Живой прогон 03.08.2026: «Исправить» открыл редактор НЕ на «Сообщении», как + // было 28.07, а сразу на «Стоимости» (/budget) — робот падал на входе, и пересдача + // была невозможна вообще. Шаг больше НЕ предполагаем: ждём любой шаг этой кампании + // и возвращаем, какой открылся. Проверка id остаётся жёсткой — на чужой кампании + // не работаем. + const urlRe = new RegExp(`telegram-a2p/${id}/(?:audience|message|budget|confirmation|payment)$`); + try { + await page.waitForURL(urlRe, { timeout: 60000 }); + } catch { + throw new Error(`Пересдача: после «Исправить» ожидали шаг мастера кампании ${id}, а URL = ${page.url()}`); + } + const step = wizardStepFromUrl(page.url()); + await humanPause(config); + return step; +} + +// Возврат по мастеру назад до нужного шага кнопкой «Назад». Нужен при пересдаче: +// исправления текста/ссылки/ОРД живут на «Сообщении», и если кабинет открыл нас +// на «Стоимости», без возврата мы пересдали бы кампанию БЕЗ правок — и получили бы +// тот же отказ, потратив цикл модерации. +// +// «Назад» — не денежная кнопка, но на всякий случай идём через domClickButton с +// его защитой F6. Шагов между «Аудиторией» и «Оплатой» пять — больше 4 возвратов +// не бывает; не дошли — падаем громко. +export async function goBackToStep(page, config, target) { + for (let i = 0; i < 4; i++) { + const step = wizardStepFromUrl(page.url()); + if (step === target) return step; + const res = await domClickButton(page, /^назад$/); + if (res === null) throw new Error(`Возврат к шагу «${target}»: кнопка «Назад» не найдена (стоим на ${step ?? page.url()})`); + if (res === 'disabled') throw new Error(`Возврат к шагу «${target}»: кнопка «Назад» неактивна (стоим на ${step ?? page.url()})`); + try { + await page.waitForURL((u) => wizardStepFromUrl(u.toString()) !== step, { timeout: 30000 }); + } catch { + throw new Error(`Возврат к шагу «${target}»: после «Назад» шаг не сменился (${page.url()})`); + } + await humanPause(config); + } + throw new Error(`Возврат к шагу «${target}» не удался за 4 шага, стоим на ${page.url()}`); +} + +// Полный флоу визарда (подтверждён живьём 27.07.2026): +// Знакомство → Тип («Своя база») → /audience → /message → /budget → /confirmation +// +// 🔑 Ключевая грабля: обучающий коуч-марк («Юрий Тихоненков») физически +// перекрывает кнопку «Продолжить» на КАЖДОМ шаге — его div лежит поверх кнопки +// (topmost element). Обычный клик и даже click({force:true}) — это МЫШИНЫЙ клик +// по координатам, он попадает в оверлей, а не в кнопку, и шаг не переключается. +// Надёжно работает только нативный .click() прямо на DOM-элементе кнопки: +// событие уходит в саму кнопку, коуч-марк ни при чём (см. domClickButton). + +// Нативный клик по кнопке с текстом, подходящим под textRe (обходит коуч-марк). +// Возвращает текст нажатой кнопки, 'disabled' если она неактивна, 'forbidden' если +// это денежная/запускающая кнопка (F6 — не жмём НИКОГДА), либо null. +async function domClickButton(page, textRe) { + return page.evaluate(({ reSrc, forbidSrc }) => { + const re = new RegExp(reSrc, 'i'); + const forbid = new RegExp(forbidSrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => re.test((b.innerText || '').trim())); + if (!btn) return null; + if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return 'disabled'; + // 🔴 Защита F6: даже если regex совпал, кнопку, ТРАТЯЩУЮ деньги/запускающую, + // не жмём — падаем наружу (сработает при смене вёрстки/слиянии шагов МТС). + if (forbid.test((btn.innerText || '').trim())) return 'forbidden'; + btn.click(); + return (btn.innerText || '').trim(); + }, { reSrc: textRe.source, forbidSrc: FORBIDDEN_BUTTON_RE.source }); +} + +// Окно-заслонка кабинета после «Продолжить» на шаге «Объявление»: «Отметили места, +// из-за которых рекламу могут отклонить — лучше сразу их поправить». Пока оно висит, +// шаг не переключается. +// +// 🔑 Снято живьём 06.08.2026 (кампания 2237812): робот его не знал, ждал перехода на +// «Стоимость» и падал невнятным «не перешли на /budget» — а деньги клиента при этом +// оставались замороженными. +const MODERATION_HINTS_RE = /из-за которых рекламу могут отклонить/i; + +/** Текст страницы — это окно замечаний модерации? */ +export function isModerationHintsText(text) { + return MODERATION_HINTS_RE.test(String(text || '')); +} + +/** + * Закрывает окно замечаний кнопкой «Пропустить». Возвращает true, если окно было. + * + * Замечания — совет кабинета, а не отказ: правит их человек, а не робот. Наше дело — + * не застрять. Вердикт всё равно вынесет модерация. + */ +export async function skipModerationHints(page, { + bodyText = (p) => p.locator('body').innerText(), + click = domClickButton, +} = {}) { + const text = await bodyText(page).catch(() => ''); + if (!isModerationHintsText(text)) return false; + + const res = await click(page, /^пропустить$/); + if (res === 'disabled') throw new Error('Кнопка «Пропустить» в окне замечаний модерации неактивна'); + if (res === null) throw new Error('Окно замечаний модерации показано, а кнопки «Пропустить» в нём нет'); + return true; +} + +// Жмём «Продолжить» (обходя коуч-марк) и убеждаемся, что перешли на ожидаемый +// URL. Иначе падаем громко с диагностикой — не заполняем «не ту» страницу. +// +// Между нажатием и переходом кабинет может показать окно замечаний модерации — +// его закрываем и ждём дальше, а не объявляем поломкой. +async function continueTo(page, config, urlRe, stepName) { + const res = await domClickButton(page, /^продолжить$/); + if (res === 'disabled') throw new Error(`Кнопка «Продолжить» неактивна на шаге «${stepName}» — форма не заполнена/не готова`); + if (res === null) throw new Error(`Кнопка «Продолжить» не найдена на шаге «${stepName}»`); + + const dedlayn = Date.now() + 30000; + while (Date.now() < dedlayn) { + if (urlRe.test(page.url())) { + await humanPause(config); + return; + } + if (await skipModerationHints(page)) continue; + await page.waitForTimeout(1000); + } + + throw new Error(`После «Продолжить» не перешли на ${urlRe} (шаг «${stepName}»); текущий URL: ${page.url()}`); +} + +// Шаг «Знакомство»: «я разбираюсь» + одна сфера бизнеса, затем «Продолжить». +// Радио/сфера кликаются force (коуч-марк их не блокирует), переход — domClick. +// +// 🔑 «Знакомство» показывается ТОЛЬКО новичку. На возвратном аккаунте (после +// первой кампании) «Создать кампанию» ведёт СРАЗУ на «Тип аудитории» — шага +// «Знакомство» нет. Тогда молча пропускаем (проверено живым прогоном 27.07.2026: +// без этой проверки openTelegramWizard падал на waitFor радио). +async function passZnakomstvo(page, config) { + const radio = page.getByRole('radio', { name: 'Нет, я отлично в этом разбираюсь' }); + const shown = await radio.waitFor({ state: 'visible', timeout: 4000 }).then(() => true).catch(() => false); + if (!shown) return; + await radio.click({ force: true }); + await humanPause(config); + await page.getByText('Услуги для бизнеса').first().click({ force: true }); + await humanPause(config); + const res = await domClickButton(page, /^продолжить$/); + if (res === null) throw new Error('Кнопка «Продолжить» не найдена на шаге «Знакомство»'); + await humanPause(config); +} + +// Шаг «Тип аудитории»: «Своя база клиентов» — клик создаёт черновик и уводит +// на /cabinet/campaign/telegram-a2p/{ID}/audience. +async function chooseOwnBase(page, config) { + await page.getByText('Своя база клиентов').click({ force: true }); + await humanPause(config); +} + +// Открывает визард Telegram-кампании и доводит страницу до шага «Аудитория». +export async function openTelegramWizard(page, config) { + // Устойчивая загрузка списка кампаний (кабинет часто виснет на крутилке). + await gotoStable(page, config.telegramUrl, config); + await page.getByRole('button', { name: 'Создать кампанию' }).click({ force: true }); + await humanPause(config); + + await passZnakomstvo(page, config); + await chooseOwnBase(page, config); + + // Убеждаемся, что попали на шаг «Аудитория» (создался черновик). + try { + await page.waitForURL(/\/audience$/, { timeout: 30000 }); + } catch { + throw new Error(`Не дошли до шага «Аудитория»; текущий URL: ${page.url()}`); + } +} + +// Читает число из бокса «Не МТС» правой панели «Аудитория» (это и есть +// аудитория показа в Telegram — МТС-номера туда не попадают). Возвращает число +// или null, если бокс ещё не заполнен (матчинг не завершился). +async function readNonMts(page) { + return page.evaluate(() => { + // 🔑 В заголовке бокса «Не МТС» между словами стоит НЕРАЗРЫВНЫЙ пробел + // (код 160), а не обычный (32) — прямое сравнение с 'Не МТС' не срабатывает. + // Нормализуем: \s покрывает и nbsp. Ищем лист-узел «Не МТС», поднимаемся до + // контейнера, где рядом стоит число: «Не МТС 437». + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const labels = [...document.querySelectorAll('div,span,p')] + .filter((el) => el.children.length === 0 && norm(el.textContent) === 'Не МТС'); + for (const label of labels) { + let el = label; + for (let up = 0; up < 3 && el; up++, el = el.parentElement) { + const m = norm(el.innerText).match(/Не МТС\s*(\d[\d ]*)/); + if (m) { + const n = Number(m[1].replace(/\D/g, '')); + if (n > 0) return n; + } + } + } + // Запасной путь: по всему тексту страницы (\s матчит nbsp). «Не МТС» с + // заглавной буквы встречается только в боксе, в прозе — строчное «не МТС». + const mb = (document.body.innerText || '').match(/Не\s*МТС[\s\S]{0,8}?(\d[\d\s]*)/); + if (mb) { + const n = Number(mb[1].replace(/\D/g, '')); + if (n > 0) return n; + } + return null; + }); +} + +// Загружает файл с базой телефонов на шаге «Аудитория», дожидается асинхронного +// матчинга и возвращает число «Не МТС» — аудиторию показа. +export async function uploadAudience(page, config, phonesFilePath) { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(phonesFilePath); + + // Матчинг «свой список ↔ база МТС» асинхронный (10–30с). Признак готовности — + // появление числа в боксе «Не МТС». Поллим до 90с. + let matched = null; + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + matched = await readNonMts(page).catch(() => null); + if (Number.isFinite(matched) && matched > 0) break; + await new Promise((r) => setTimeout(r, 2000)); + } + if (!Number.isFinite(matched) || matched <= 0) { + throw new Error('Матчинг аудитории не завершился за 90с — число «Не МТС» не появилось'); + } + + // Санити «дёшево»: «Не МТС» не может превышать «Получатели» (всего принято + // номеров). Если «Получатели» не распарсилось — просто пропускаем проверку. + const recip = page.getByText('Получатели', { exact: true }).first(); + if (await recip.isVisible().catch(() => false)) { + const rtxt = await recip.locator('xpath=..').innerText().catch(() => ''); + const rm = rtxt.match(/\d[\d\s]*/); + const recipients = rm ? Number(rm[0].replace(/\D/g, '')) : NaN; + if (Number.isFinite(recipients) && matched > recipients) { + throw new Error(`Прочитано «Не МТС»=${matched}, но это больше «Получатели»=${recipients} — число считано неверно`); + } + } + return matched; +} + +// Переход «Аудитория» → «Объявление». +export async function submitAudience(page, config) { + await continueTo(page, config, /\/message$/, 'Аудитория'); + // Само-верификация: на шаге объявления есть поле текста. + await page.locator('textarea[placeholder^="Расскажите о боте"]').waitFor({ timeout: 30000 }); +} + +// Заполняет шаг «Объявление» (/message): текст, ссылку «Что рекламируете», +// (опц.) медиа Premium и ОБЯЗАТЕЛЬНУЮ категорию ОРД. Кнопку «Продолжить» НЕ +// жмёт — переход делает submitAd (своя граница ответственности). +// +// Подтверждено живьём: поля «Заголовок» НЕТ, поля «Название компании» НЕТ — +// рекламодатель (наш ИП «Кондратьев Платон Дмитриевич, ИНН 245210851872») и +// маркировка ОРД подставляются кабинетом автоматически. +export async function fillAd(page, config, task) { + await fillAdText(page, config, task.adText); + if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile); + await fillAdLink(page, config, task.buttonUrl); + // Заголовок — ПОСЛЕ ссылки: поле появляется в ответ на ссылку на сайт. + await fillAdHeadline(page, config, task.adHeadline); + await selectOrdCategory(page, config, task.ordCategory); + // Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем. + if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile); +} + +// Текст объявления (textarea «Расскажите о боте…»). +export async function fillAdText(page, config, text) { + await page.locator('textarea[placeholder^="Расскажите о боте"]').fill(text); + await humanPause(config); +} + +// Медиа объявления (картинка/видео Premium) — отдельный от документа модератору +// file-input (accept image/video). +export async function fillAdMedia(page, config, mediaFile) { + await page + .locator('input[type="file"][accept*="image"], input[type="file"][accept*="video"]') + .setInputFiles(mediaFile); + await humanPause(config); +} + +// Заголовок объявления — поле «Название компании, сервиса, товара или услуги» +// (до 40 знаков). Кабинет рисует его ТОЛЬКО для рекламы сайта, поэтому наличие +// поля СПРАШИВАЕМ У СТРАНИЦЫ, а не угадываем по виду ссылки: правило «сайт vs +// канал» — их, а не наше, и может поменяться без предупреждения. +export async function fillAdHeadline(page, config, headline) { + // 🪤 Ловушка, стоившая четырёх живых прогонов 31.07.2026: серая надпись + // «Название компании, сервиса, товара или услуги» внутри поля — это НЕ атрибут + // placeholder, а нарисованная подпись. Поиск `input[placeholder^="Название + // компании"]` находил НОЛЬ совпадений, при том что поле было на экране (видно + // на снимке), и робот молча его пропускал. Опознаём поле так же, как человек: + // «первый ввод после видимой надписи "Заголовок объявления"» — проверено + // замером: 1 совпадение, текст доезжает. + // Кабинет дорисовывает блок в ответ на ссылку с задержкой — поэтому ЖДЁМ. + const pole = page + .locator('xpath=//*[contains(normalize-space(text()),"Заголовок объявления")]/following::input[1]') + .first(); + const estPole = await pole + .waitFor({ state: 'visible', timeout: 8000 }) + .then(() => true) + .catch(() => false); + + assertHeadlineWhenRequired(estPole, headline); + if (!estPole) return; + + // Кабинет режет по 40 знаков молча — режем сами, чтобы отправленное совпадало + // с тем, что видит клиент в портале. + await pole.fill(headline.trim().slice(0, 40)); + await humanPause(config); +} + +// Ссылка «Что рекламируете» (канал/бот/сайт). +export async function fillAdLink(page, config, url) { + await page.locator('input[placeholder^="Ссылка на канал"]').fill(url); + await humanPause(config); +} + +// Категория ОРД — обязательное поле блока «Информация для ОРД». +// downshift-combobox: клик → очистить → ввод по буквам → дождаться опций → клик по +// опции. Синтетический ввод/выбор валидацию не проходит — нужен настоящий клик по +// элементу списка (подтверждено живьём: срабатывает с 1-й попытки; значение поля +// после выбора = выбранной категории). +export async function selectOrdCategory(page, config, category) { + const kktu = page.locator('input[name="selectedKktu"]'); + await kktu.click({ force: true }); + await humanPause(config); + await kktu.fill(''); + await kktu.pressSequentially(category, { delay: 60 }); + + await page.getByRole('option').first().waitFor({ timeout: 10000 }); + let option = page.getByRole('option', { name: category, exact: true }).first(); + if (!(await option.isVisible().catch(() => false))) option = page.getByRole('option').first(); + await option.click({ force: true }); + await humanPause(config); + + const chosen = await kktu.inputValue().catch(() => ''); + if (!chosen || /не выбрана/i.test(chosen)) { + throw new Error(`Категория ОРД не проставилась (значение поля: "${chosen}")`); + } +} + +// Правка полей объявления при ПЕРЕСДАЧЕ (robot-режим resubmit): в редакторе +// отклонённой кампании (шаг «Сообщение») перезаписывает ТОЛЬКО заданные поля — +// текст/ссылку/категорию ОРД — как ответ на причину отказа. null-поля не трогаем +// (у кампании они уже заполнены). Документ модератору грузится отдельно +// (attachModeratorDoc). Переиспользует те же проверенные хелперы, что и fillAd. +export async function editResubmitFields(page, config, task) { + if (task.adText) await fillAdText(page, config, task.adText); + if (task.buttonUrl) await fillAdLink(page, config, task.buttonUrl); + if (task.ordCategory) await selectOrdCategory(page, config, task.ordCategory); +} + +// Грузит документ в поле «Комментарий для модератора» блока «Дополнительная +// информация» шага «Сообщение». Это ТРЕТИЙ file-input шага — единственный, +// принимающий pdf (медиа-инпуты объявления выше — image/video). Общий для первичного +// создания (fillAd) и пересдачи (runResubmit). +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): документ загружен в поле «Комментарий для +// модератора» кампании 2231134 (accept `.png,.jpeg,.jpg,.pdf`). +export async function attachModeratorDoc(page, config, moderatorFile) { + const docInput = page.locator('input[type="file"][accept*="pdf"]').first(); + await docInput.setInputFiles(moderatorFile); + await humanPause(config); +} + +// Переход «Объявление» → «Стоимость» (/budget). +export async function submitAd(page, config) { + await continueTo(page, config, /\/budget$/, 'Объявление'); +} + +// Переход «Стоимость» → «Подтверждение» (/confirmation). +// +// На шаге /budget стоимость НЕ задаётся полем «бюджет» — она ВЫЧИСЛЯЕТСЯ +// кабинетом: аудитория «не МТС» × фиксированная ставка (≈400 ₽ / 1000 показов). +// Настраиваемое здесь (частота показов, даты старта/завершения, расписание, +// «Автозапуск») для базовой кампании остаётся на дефолтах: показать 1 раз, +// даты «сегодня → +7 дней», Автозапуск ВКЛ (запуск после модерации). +// +export async function submitBudget(page, config) { + // 🔴 Живой прогон 03.08.2026: у ОТКЛОНЁННОЙ кампании расписание остаётся прошлым + // (28.07–04.08), и кабинет дальше не пускает — «Измените дату начала, выбранная + // уже прошла». Коварство в том, что «Продолжить» при этом АКТИВНА и нажимается, + // просто ничего не происходит: робот молча стоял до тайм-аута, а наверх уходило + // невнятное «не перешли на /confirmation». Чиним ДО клика. Для новой кампании + // даты свежие — проверка сама себя выключает. + await fixPastDatesIfNeeded(page, config); + await continueTo(page, config, /\/confirmation$/, 'Стоимость'); +} + +// Переставляет даты старта/завершения на будущие, если кабинет жалуется на +// прошедшую дату старта. Ничего не жмёт, кроме клеток календаря. +// +// Даты считает datesForRestart (чистая, покрыта тестами); «сегодня» передаётся — +// иначе функцию нельзя проверить. +// +// 🪤 Клик по клетке выбирает день ПОКАЗАННОГО месяца. Если новые даты уезжают в +// следующий — падаем громко: перелистывание месяца живьём не снято, а кликнуть +// «5» в августе вместо сентября значит молча выставить не ту дату. +export async function fixPastDatesIfNeeded(page, config, segodnya = new Date()) { + const text = await page.evaluate(() => (document.body.innerText || '').replace(/\s+/g, ' ').trim()); + if (!isPastStartDateMessage(text)) return { pravili: false }; + + const d = datesForRestart(segodnya); + if (!d.odinMesyac) { + throw new Error(`Даты пересдачи (${d.startStroka}–${d.konecStroka}) уходят в следующий месяц — перелистывание календаря не поддержано, нужен человек`); + } + + const res = await page.evaluate(({ den1, den2 }) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const dni = [...document.querySelectorAll('button,[role=button]')] + .filter((b) => /^\d{1,2}$/.test(norm(b.innerText))); + // Календарей на шаге два подряд: старта и завершения. Граница — там, где + // номер дня перестаёт расти. + let granica = dni.length; + for (let i = 1; i < dni.length; i++) { + if (+norm(dni[i].innerText) <= +norm(dni[i - 1].innerText)) { granica = i; break; } + } + const zhmi = (spisok, den) => { + const b = spisok.find((x) => norm(x.innerText) === String(den)); + if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false; + b.click(); + return true; + }; + return { + start: zhmi(dni.slice(0, granica), den1), + konec: zhmi(dni.slice(granica), den2), + }; + }, { den1: d.startDen, den2: d.konecDen }); + + if (!res.start || !res.konec) { + throw new Error(`Не удалось выставить даты пересдачи (${d.startStroka}–${d.konecStroka}): клетка календаря не найдена или неактивна`); + } + await humanPause(config); + + // Проверяем ГЛАЗАМИ страницы, а не верим клику: в полях должны стоять наши даты. + const polya = await page.evaluate(() => [...document.querySelectorAll('input')] + .map((i) => i.value).filter((v) => /^\d{2}\.\d{2}\.\d{4}$/.test(v || ''))); + if (!polya.includes(d.startStroka) || !polya.includes(d.konecStroka)) { + throw new Error(`Даты не встали: ожидали ${d.startStroka} и ${d.konecStroka}, в полях ${JSON.stringify(polya)}`); + } + + return { pravili: true, start: d.startStroka, konec: d.konecStroka }; +} + +// Финализация на шаге «Подтверждение» (/confirmation): сводка кампании, поле +// названия (авто), чекбокс оферты и кнопка отправки на модерацию. +// +// Режим 'draft' — предохранитель: доходим до финала, снимаем скриншот (главный +// артефакт «дошли до кнопки, не нажали»), НИЧЕГО не отправляем, деньги/модерация +// не трогаются. Режим 'live' — соглашаемся с офертой и отправляем на модерацию. +export async function finalize(page, config, mode, screenshotPath) { + // Убеждаемся, что мы действительно на «Подтверждении». + try { + await page.waitForURL(/\/confirmation$/, { timeout: 30000 }); + } catch { + throw new Error(`finalize: ожидали шаг «Подтверждение» (/confirmation), а URL = ${page.url()}`); + } + await page.getByText('Я соглашаюсь с условиями оферты').first().waitFor({ timeout: 20000 }); + + // Скриншот шага — ДО любого клика, в обеих ветках. ПДн-гейт (152-ФЗ): + // полноэкранный скрин кабинета может содержать телефоны клиентской базы — + // снимаем ТОЛЬКО при явном TG_DEBUG_SHOTS, по умолчанию пропускаем. + if (debugShotsEnabled(config)) { + await page.screenshot({ path: screenshotPath, fullPage: true }); + } + + const campaignId = parseCampaignId(page.url()) ?? 'unknown'; + + if (mode === 'draft') { + return { launched: false, campaignId }; + } + // Защита от боевой отправки по ошибке: клик по финальной кнопке допустим + // ТОЛЬКО при явном mode === 'live'. Иначе — падаем громко. + if (mode !== 'live') { + throw new Error(`finalize: неизвестный режим "${mode}" — ожидается 'draft' или 'live'`); + } + + // LIVE: согласие с офертой + «Продолжить» на /confirmation. + // Оферта — точный чекбокс `#isOfferAccepted` (снят живьём 27.07.2026). + await page.locator('#isOfferAccepted').click({ force: true }); + await humanPause(config); + + // F8: перед продвижением к оплате сверяем фактическую стоимость с потолком, если + // её видно в сводке. Нечитаемо (селектор сводки живьём не подтверждён) → не + // блокируем: реального списания на этом шаге НЕТ (/payment wire-ится в Сессии 6). + // + try { + const costText = await page.getByText(/Стоимость|Итого/i).first().innerText({ timeout: 3000 }); + assertCostWithinCap(parseCost(costText), Number(config.budgetCapRub)); + } catch (e) { + if (/потолок/i.test(e.message)) throw e; // над потолком / некорректный потолок — стоп + // иначе сводку не прочитали — идём дальше (best-effort) + } + + // F6: целим ТОЛЬКО кнопку продолжения/отправки на модерацию; денежные кнопки + // domClickButton вернёт как 'forbidden' — падаем громко, а не тратим. + const clicked = await domClickButton(page, SUBMIT_BUTTON_RE); + if (!clicked || clicked === 'disabled' || clicked === 'forbidden') { + throw new Error(`finalize live: кнопка отправки не найдена/неактивна/денежная (результат: ${clicked})`); + } + await humanPause(config); + + // 🔑 После «Продолжить» — шаг /payment «Подтверждение оплаты». Робот дальше НЕ идёт: + // реальная отправка/оплата («Списать с баланса…» / «…без оплаты») wire-ится в + // Сессии 6. F7: кампания на модерацию ещё НЕ ушла → launched:false, stoppedAt:'payment' + // (НЕ врём про запуск — иначе портал заморозит деньги за «запущенную» кампанию, + // висящую неоплаченной). Не дошли до /payment → падаем громко. + try { + await page.waitForURL(/\/payment$/, { timeout: 30000 }); + } catch { + throw new Error(`finalize live: после подтверждения ожидали /payment, а URL = ${page.url()}`); + } + return { launched: false, campaignId, stoppedAt: 'payment' }; +} + +// Отправка кампании на модерацию БЕЗ ОПЛАТЫ (0 ₽) на шаге /payment — финал robot- +// режима пересдачи (R4). Целит ТОЛЬКО кнопку «Отправить на модерацию без оплаты» +// (isNoPaymentSubmitText); денежные («Списать с баланса…», «Оплатить») НЕ жмёт +// никогда — двойная защита через isForbiddenButtonText. Убеждаемся, что ДО клика мы +// на /payment; после клика — что ушли с /payment (кампания встала на модерацию). +// Кнопки без-оплаты нет / она денежная / не на /payment → падаем громко. +// +// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): клик «без оплаты» кампании 2231134 — +// баланс не тронут, кампания вернулась в «На модерации». +export async function submitWithoutPayment(page, config) { + try { + await page.waitForURL(/\/payment$/, { timeout: 30000 }); + } catch { + throw new Error(`submitWithoutPayment: ожидали шаг /payment, а URL = ${page.url()}`); + } + await humanPause(config); + + const res = await page.evaluate((noPaySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const noPay = new RegExp(noPaySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => noPay.test(norm(b.innerText))); + if (!btn) return { status: 'кнопка-без-оплаты-не-найдена' }; + if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') + return { status: 'disabled' }; + return { status: 'ready', text: norm(btn.innerText) }; + }, NO_PAYMENT_SUBMIT_RE.source); + + if (res.status !== 'ready') { + throw new Error(`submitWithoutPayment: кнопка «без оплаты» ${res.status}`); + } + // Двойная защита: даже если regex без-оплаты совпал, текст кнопки не должен быть + // денежным (защита от смены вёрстки МТС, слияния «списать» и «без оплаты»). + if (isForbiddenButtonText(res.text)) { + throw new Error(`submitWithoutPayment: кнопка «${res.text}» опознана как денежная — не жму`); + } + + await page.evaluate((noPaySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const noPay = new RegExp(noPaySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => noPay.test(norm(b.innerText))); + if (btn) btn.click(); + }, NO_PAYMENT_SUBMIT_RE.source); + + // После отправки кабинет уводит со /payment (в список / на страницу кампании). + try { + await page.waitForURL((url) => !/\/payment$/.test(url.toString()), { timeout: 30000 }); + } catch { + throw new Error(`submitWithoutPayment: после клика «без оплаты» всё ещё на /payment (${page.url()})`); + } + await humanPause(config); + return { submitted: true }; +} + +// 🔴 ОПЛАТА кампании на шаге /payment — единственное место, где робот тратит живые +// деньги с баланса кабинета МТС. Разрешено владельцем 03.08.2026 «но с потолком». +// +// Порядок жёсткий и менять его нельзя: +// 1. убедиться, что мы на /payment (иначе не жмём вообще); +// 2. ПРОЧИТАТЬ сумму к оплате со страницы; +// 3. прогнать её через гейт assertPayable против меньшего из двух потолков; +// 4. и только теперь искать кнопку и жать. +// Сумма не прочиталась — не платим. Это не перестраховка: до 03.08 робот вообще не +// доходил до кассы, и клиент платил порталу за рекламу, которой не было ни секунды. +// +// Возвращает { paid: true, actualCostRub } — actualCostRub это НАШИ расходы у МТС +// (резерв), из них портал считает, сколько взять с клиента. +export async function submitWithPayment(page, config, { budgetRub }) { + try { + await page.waitForURL(/\/payment$/, { timeout: 30000 }); + } catch { + throw new Error(`submitWithPayment: ожидали шаг оплаты (/payment), а URL = ${page.url()}`); + } + await humanPause(config); + + // Сумма к оплате. Точный селектор строки живьём не снят (кассу до сих пор не + // проходили), поэтому берём текстом страницы по подписи из разведки 27.07.2026 + // («Сумма к оплате», запасное — «Стоимость кампании»). Не нашли подпись → null → + // гейт ниже не пустит. + const costText = await page.evaluate(() => { + const body = (document.body.innerText || '').replace(/ /g, ' '); + for (const label of ['Сумма к оплате', 'Стоимость кампании', 'Итого']) { + const i = body.indexOf(label); + if (i >= 0) return body.slice(i, i + 120); + } + return null; + }); + + const costRub = parseCost(costText); + const cap = payCapRub(budgetRub, Number(config.budgetCapRub)); + assertPayable(costRub, cap); + + // Кнопку ищем по её точному тексту. Двойная защита: найденный текст ещё раз + // прогоняем через isPaySubmitText уже здесь, в Node — на случай, если вёрстка МТС + // склеит платящую кнопку с чем-то ещё. + const found = await page.evaluate((paySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const pay = new RegExp(paySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => pay.test(norm(b.innerText))); + if (!btn) return { status: 'кнопка-оплаты-не-найдена' }; + if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') + return { status: 'disabled', text: norm(btn.innerText) }; + return { status: 'ready', text: norm(btn.innerText) }; + }, PAY_SUBMIT_RE.source); + + if (found.status === 'disabled') { + throw new Error(`submitWithPayment: кнопка оплаты неактивна — вероятнее всего не хватает денег на балансе кабинета МТС (нужно ${costRub}₽)`); + } + if (found.status !== 'ready') { + throw new Error(`submitWithPayment: ${found.status}`); + } + if (!isPaySubmitText(found.text)) { + throw new Error(`submitWithPayment: текст кнопки «${found.text}» не опознан как платящий — не жму`); + } + + await page.evaluate((paySrc) => { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const pay = new RegExp(paySrc, 'i'); + const btn = [...document.querySelectorAll('button,[role=button]')] + .find((b) => pay.test(norm(b.innerText))); + if (btn) btn.click(); + }, PAY_SUBMIT_RE.source); + + // Ушли со /payment — значит кампания принята и встала на модерацию. Остались — + // падаем громко: молчаливое «вроде заплатили» здесь недопустимо, портал по нашему + // отказу вернёт клиенту деньги. + try { + await page.waitForURL((url) => !/\/payment$/.test(url.toString()), { timeout: 30000 }); + } catch { + throw new Error(`submitWithPayment: после клика оплаты всё ещё на /payment (${page.url()}) — считаем, что оплата не прошла`); + } + await humanPause(config); + + return { paid: true, actualCostRub: costRub }; +} diff --git a/bots/mts-telegram-ads/src/keepalive-plan.js b/bots/mts-telegram-ads/src/keepalive-plan.js index d49bc04e..571a106b 100644 --- a/bots/mts-telegram-ads/src/keepalive-plan.js +++ b/bots/mts-telegram-ads/src/keepalive-plan.js @@ -3,10 +3,12 @@ // Отдельным файлом от bin/keepalive.js нарочно — тем же приёмом, что и poll-plan.js: // так решение можно проверить тестами без браузера и без сети. // -// 🔴 Правка 07.08.2026. Сторож поднимал браузер БЕЗ ОКНА (`headless: true`), а настоящая -// работа робота ходит С ОКНОМ (умолчание `openBrowser`). Для защиты МТС это два разных -// браузера: сторож ловил отказ 403 там, где работа спокойно проходила, и владелец -// получал письмо каждый час о беде, которой не было. +// 🔴🔴🔴 Правка 07.08.2026, разрешена владельцем. НЕ ВОЗВРАЩАТЬ `{ headless: true }`. +// +// Сторож поднимал браузер БЕЗ ОКНА, а настоящая работа робота ходит С ОКНОМ (умолчание +// `openBrowser`). Для защиты МТС это два разных браузера: сторож ловил отказ 403 там, +// где работа спокойно проходила, и владелец получал письмо каждый час о беде, которой +// не было. // // Замер, одна ось, два порядка, тот же профиль и тот же адрес выхода: // сперва без окна → без окна НЕ ПУСКАЕТ, следом с окном ПУСКАЕТ; @@ -17,6 +19,9 @@ // // Правило простое: сторож обязан ходить ТОЧНО так же, как ходит работа. Иначе он мерит // не то, что работает, и его тревога ничего не значит. +// +// Разбор целиком — docs/superpowers/2026-08-07-PROMT-mts-bot-kabinet-otbivaet-brauzer.md +// в репозитории Лидерры (там же опровержение версии «МТС заблокировал адрес»). export function keepaliveBrowserOpts() { return {}; } diff --git a/bots/mts-telegram-ads/src/poll-plan.js b/bots/mts-telegram-ads/src/poll-plan.js index 42e0e6ee..c1c46460 100644 --- a/bots/mts-telegram-ads/src/poll-plan.js +++ b/bots/mts-telegram-ads/src/poll-plan.js @@ -7,6 +7,7 @@ export function planForJob(job) { if (job.mode === 'read-status') return { needsPhones: false, kind: 'read-status' }; if (job.mode === 'resubmit') return { needsPhones: false, kind: 'resubmit' }; + // П‑7 (07.08.2026): чтение метрик показа. Номера не нужны — цифры лежат в списке. if (job.mode === 'read-stats') return { needsPhones: false, kind: 'read-stats' }; // draft и live — запуск кампании. Незнакомый режим сюда же: портал шлёт только diff --git a/bots/mts-telegram-ads/src/runner.js b/bots/mts-telegram-ads/src/runner.js index 43ee4f62..53a54b0f 100644 --- a/bots/mts-telegram-ads/src/runner.js +++ b/bots/mts-telegram-ads/src/runner.js @@ -6,27 +6,29 @@ import { openTelegramWizard, uploadAudience, submitAudience, fillAd, submitAd, submitBudget, finalize, parseCampaignId, readModerationStatus, readCampaignStats, MIN_NON_MTS, openResubmitEditor, attachModeratorDoc, editResubmitFields, submitWithoutPayment, submitWithPayment, - // 🔴 07.08.2026: здесь висел неразрешённый конфликт слияния от прошлой смены. Сторона - // без `goBackToStep` неверна — функция вызывается ниже в runResubmit (шаг 'resubmit-back'), - // и без импорта робот падал бы на КАЖДОЙ пересдаче. Оставлена сторона с импортом. goBackToStep, } from './cabinet.js'; import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'; import { debugShotsEnabled, alarmScreenshot } from './shots.js'; +import { otpravitTrevogu, otpravitOtchyot } from './trevoga.js'; // Режим read-status (задача 3.5): открывает кабинет и читает вердикт модерации // кампании по её id. Ни номеров, ни отправки, ни почты — ТОЛЬКО чтение. Возвращает -// robot-JSON { ok, moderationStatus, reason?, campaignId } — его разбирает +// robot-JSON { ok, moderationStatus, reason?, startDate?, campaignId } — его разбирает // RobotResult.fromRobotJson на стороне Laravel (задача 3.4). Ошибка чтения → // { ok:false, reason } (опросчик оставит кампанию на модерации до следующего цикла). +// +// startDate ('ГГГГ-ММ-ДД' или null) — день, с которого кабинет начнёт показы. Портал +// дат кабинету не задаёт, тот ставит свои; клиенту эту дату надо показать, иначе он +// ждёт рекламу сегодня, а она стартует завтра. export async function runReadStatus(config, campaignId) { const id = String(campaignId); let page, context; try { ({ context, page } = await openBrowser(config, {})); await ensureLoggedIn(page, config); - const { moderationStatus, reason } = await readModerationStatus(page, config, id); - return { ok: true, campaignId: id, moderationStatus, reason }; + const { moderationStatus, reason, startDate } = await readModerationStatus(page, config, id); + return { ok: true, campaignId: id, moderationStatus, reason, startDate }; } catch (e) { return { ok: false, step: 'read-status', reason: e.message, campaignId: id, retryable: e.retryable === true }; } finally { @@ -36,10 +38,10 @@ export async function runReadStatus(config, campaignId) { } } -// Режим read-stats (П‑7): открывает список кабинета и читает метрики показа -// кампании — показы, клики, подписчиков и расход площадки. Ни номеров, ни отправки, -// ни почты, кнопок кампании не касается — ТОЛЬКО чтение. Возвращает robot-JSON -// { ok, campaignId, status, impressions, clicks, subscribers, spendRub }; значение +// Режим read-stats (П‑7, добавлен 07.08.2026): открывает список кабинета и читает +// метрики показа кампании — показы, клики, подписчиков и расход площадки. Ни номеров, +// ни отправки, ни почты, кнопок кампании не касается — ТОЛЬКО чтение. Возвращает +// robot-JSON { ok, campaignId, status, impressions, clicks, subscribers, spendRub }; // null означает «площадка данных не дала» и НАВЕРХ едет именно как «не знаем». export async function runReadStats(config, campaignId) { const id = String(campaignId); @@ -153,8 +155,9 @@ export async function runTask(config, task, mailer, { timestamp }) { } // Письмо-отчёт — best-effort: запуск уже состоялся, сбой отправки письма - // НЕ должен превращать успешный результат в ok:false. - try { await mailer.report({ campaignId: res.campaignId ?? 'draft', matched, mode: task.mode }); } catch {} + // НЕ должен превращать успешный результат в ok:false. Но и молчать о таком сбое + // нельзя: голый `catch {}` здесь двое суток прятал «553 Sender address rejected». + await otpravitOtchyot({ mailer }, { campaignId: res.campaignId ?? 'draft', matched, mode: task.mode }); // Ранний id — запасной, id из finalize (res.campaignId) приоритетен: ...res идёт // ПОСЛЕ и перекрывает. Оплата идёт последней: она перебивает launched:false и // stoppedAt:'payment' из finalize — кампания у кассы больше не стоит. @@ -172,8 +175,9 @@ export async function runTask(config, task, mailer, { timestamp }) { // реально создан — иначе nodemailer падает на несуществующем вложении (ENOENT). const shot = alarmScreenshot(config, screenshot, existsSync(screenshot)); // Сам алярм — тоже best-effort: сбой SMTP не должен мешать finally - // (закрытию браузера/чистке ПДн) и не должен превращаться в throw. - try { await mailer.alarm({ step, reason: e.message, screenshotPath: shot }); } catch {} + // (закрытию браузера/чистке ПДн) и не должен превращаться в throw. Но сбой почты + // обязан оставить след — иначе «письма о сбоях не приходят» не с чего распутывать. + await otpravitTrevogu({ mailer }, { step, reason: e.message, screenshotPath: shot }); // retryable — временный отказ (кабинет завис на пустой странице). Портал по этому // признаку вернёт задание в очередь вместо того, чтобы хоронить кампанию клиента. return { ok: false, step, reason: e.message, campaignId, retryable: e.retryable === true }; diff --git a/bots/mts-telegram-ads/src/session.js b/bots/mts-telegram-ads/src/session.js index 5c67a89f..0c4af3a7 100644 --- a/bots/mts-telegram-ads/src/session.js +++ b/bots/mts-telegram-ads/src/session.js @@ -37,12 +37,19 @@ export class CabinetNotRenderedError extends Error { } } -/** МТС не пускает этот адрес. Повтор не поможет — нужен другой адрес. */ +/** + * МТС отказал по адресу — и отказ держался все попытки подряд. + * + * 🔑 Отказ ВРЕМЕННЫЙ (приёмка 06.08.2026): один и тот же адрес за четверть часа дал + * «доступ запрещён» дважды подряд и через пять минут дважды пустил, без единой правки + * с нашей стороны. Поэтому задание возвращается в очередь, а не хоронится. Настоящий + * бан выест все попытки на портале (max_attempts) и всплывёт честно. + */ export class CabinetBlockedError extends Error { constructor(message) { super(message); this.name = 'CabinetBlockedError'; - this.retryable = false; + this.retryable = true; } } @@ -56,15 +63,22 @@ export const LOGIN_MARKERS = [ /** * Что сказать человеку, когда кабинет отдал «Доступ запрещён». * - * 🔴 Прежний текст советовал «роботу нужен другой адрес выхода», и совет был ОШИБОЧНЫЙ. - * Разбор 07.08.2026 показал: адрес один и тот же, он не заблокирован, обычный запрос с - * той же машины в ту же минуту получает нормальный ответ. Отказ ловил безоконный браузер - * сторожа на протухшем пропуске — а смена потратила разбор на поиск блокировки адреса. - * Подсказка, уводящая не туда, дороже отсутствия подсказки. + * 🔴🔴🔴 Правка 07.08.2026, разрешена владельцем. НЕ ВОЗВРАЩАТЬ прежний текст + * «роботу нужен другой адрес выхода» — он ОШИБОЧНЫЙ и дорогой. + * + * Разбор 07.08.2026 доказал: адрес один и тот же, он НЕ заблокирован — обычный запрос с + * той же машины в ту же минуту получает нормальный ответ и напрямую, и через прокси. + * Отказ ловил безоконный браузер сторожа на протухшем пропуске. Прежняя подсказка увела + * целую смену на многочасовой поиск блокировки адреса, которой не существовало. + * Подсказка, уводящая не туда, хуже отсутствия подсказки. + * + * Адрес упоминаем НАРОЧНО — чтобы отвести от него следующего разбирающего. */ -export const BLOCK_MESSAGE = - 'МТС не пустил робота в кабинет. Адрес выхода тут почти всегда ни при чём — ' - + 'обычно протух пропуск в профиле браузера. Лечится входом руками (npm run login).'; +export function blockMessage(attempts) { + return `МТС не пустил робота в кабинет — отказ ${attempts} раз подряд. ` + + 'Адрес выхода тут почти всегда ни при чём: обычно протух пропуск в профиле ' + + 'браузера. Лечится входом руками (npm run login).'; +} /** Приметы отказа по адресу (анти-бот МТС). */ export const BLOCK_MARKERS = [ @@ -136,6 +150,8 @@ export async function ensureLoggedIn(page, config, { pauseMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = {}) { + let otkazovPodryad = 0; + for (let i = 1; i <= attempts; i++) { let state; try { @@ -152,13 +168,17 @@ export async function ensureLoggedIn(page, config, { ); } - if (state === 'zablokirovan') { - throw new CabinetBlockedError(BLOCK_MESSAGE); - } + // Отказ по адресу — не приговор: повторяем, как зависшую страницу. Про адрес + // говорим, только если он отказал ВСЕ попытки подряд. + otkazovPodryad = state === 'zablokirovan' ? otkazovPodryad + 1 : 0; if (i < attempts) await sleep(pauseMs); } + if (otkazovPodryad === attempts) { + throw new CabinetBlockedError(blockMessage(attempts)); + } + throw new CabinetNotRenderedError( `Кабинет МТС не открылся — пустая страница после ${attempts} попыток`, ); diff --git a/bots/mts-telegram-ads/src/sluzhba-plan.js b/bots/mts-telegram-ads/src/sluzhba-plan.js new file mode 100644 index 00000000..69029240 --- /dev/null +++ b/bots/mts-telegram-ads/src/sluzhba-plan.js @@ -0,0 +1,79 @@ +export const MINUTA = 60_000; +export const CHAS = 60 * MINUTA; + +/** + * Что службе делать прямо сейчас: 'keepalive' | 'poll' | 'zhdat'. + * + * Решение вынесено отдельно от самой петли нарочно: петля вечная и со сном внутри, + * её тестом не поймать, а вот выбор действия — обычная арифметика, и именно в нём + * живут все опасные случаи. + * + * 🔴 Порядок правил не косметика: + * 1. Заход уже идёт — ждём ВСЕГДА. Профиль браузера один на робота и сторожа; + * два захода разом дерутся за него. Раньше от этого берёг сам Планировщик + * (`MultipleInstances = IgnoreNew`) — уходя с него, защиту уносим с собой. + * 2. Сторож важнее работы. Если вход в кабинет мёртв, заход за работой всё равно + * упрётся в мёртвую сессию и потратит попытку, а сторож ответит дешевле. + */ +export function sleduyushchiyShag({ idyot, poslednijPoll, poslednijKeepalive, seychas }) { + if (idyot) return 'zhdat'; + if (pora(poslednijKeepalive, seychas, CHAS)) return 'keepalive'; + if (pora(poslednijPoll, seychas, MINUTA)) return 'poll'; + return 'zhdat'; +} + +/** + * Пускать ли второй экземпляр службы: 'svobodno' | 'perehvat' | 'zanyat'. + * + * 🔴 Два экземпляра полезут в кабинет одновременно и подерутся за единственный + * профиль браузера. От этого раньше берёг сам Планировщик; служба обязана беречь себя. + * + * 🪤 Замок «есть файл — не пускать» ломается при первом падении: файл остаётся от + * мёртвого процесса, и служба больше не поднимется никогда. Поэтому запертым считаем + * только замок с ЖИВЫМ номером процесса; всё остальное — брошенный замок, его + * перехватываем. + */ +export function reshenieZamka({ zapis, zhiv }) { + if (zapis === null || zapis === undefined) return 'svobodno'; + const nomer = Number(String(zapis).trim()); + if (!Number.isInteger(nomer) || nomer <= 0) return 'perehvat'; + return zhiv ? 'zanyat' : 'perehvat'; +} + +/** + * Один ли это профиль браузера — наш и тот, которым подписался процесс. + * + * 🔴 Профиль ОДИН на троих: робот, сторож и окно ручного входа владельца. Пока окно + * открыто, лезть в кабинет нельзя: второй браузер на том же профиле не встаёт, и + * схлопывается именно окно владельца — прямо посреди ввода СМС. + * + * 🪤 Сравнивать пути буква в букву НЕЛЬЗЯ. Браузер подписывается тем путём, каким тот + * записан в настройках (`MTS_BROWSER_PROFILE_DIR=C:/liderra/mts-bot-profile` — прямые + * косые черты), а служба искала обратные. Замер 06.08.2026: 7 процессов с прямыми + * против 1 случайного вспомогательного с обратными. Защита не срабатывала НИ РАЗУ — + * за всю жизнь службы в журнале нет ни одной строки «ПРОПУСК: профиль занят», и + * владелец не мог войти в кабинет руками: окно закрывалось раньше, чем он успевал. + * + * Вид косой черты и регистр — это один и тот же путь, поэтому приводим к одному виду. + */ +export function etoNashProfil(komandnayaStroka, profil) { + if (!komandnayaStroka || !profil) return false; + return kOdnomuVidu(komandnayaStroka).includes(kOdnomuVidu(profil)); +} + +function kOdnomuVidu(put) { + return String(put).replace(/\//g, '\\').toLowerCase(); +} + +/** + * Пора ли повторять действие. + * + * 🪤 Отрицательная разница — это не «ещё рано», а прыжок часов назад: перевод времени + * или синхронизация с сервером. Считать её «рано» значило бы усыпить службу на столько, + * на сколько прыгнули часы — то есть, возможно, до утра. + */ +function pora(poslednij, seychas, period) { + if (poslednij === null || poslednij === undefined) return true; + const proshlo = seychas - poslednij; + return proshlo < 0 || proshlo >= period; +} diff --git a/bots/mts-telegram-ads/src/task.js b/bots/mts-telegram-ads/src/task.js index 2bd97b77..873d7ed4 100644 --- a/bots/mts-telegram-ads/src/task.js +++ b/bots/mts-telegram-ads/src/task.js @@ -26,15 +26,11 @@ export function parseResubmitTask(raw) { const adText = opt('adText'); const buttonUrl = opt('buttonUrl'); const ordCategory = opt('ordCategory'); - // 🔴 07.08.2026 (П‑9 спеки): заголовок и медиа тоже правятся при пересдаче — проверка - // ругается на них не реже, чем на текст, а поправить их было нечем. - const adHeadline = opt('adHeadline'); - const mediaFile = opt('mediaFile'); - if (!moderatorFile && !adText && !buttonUrl && !ordCategory && !adHeadline && !mediaFile) { - throw new Error('Пересдача: нужна хоть одна правка — документ модератору (moderatorFile), новый текст/заголовок/ссылка/категория ОРД или новое медиа'); + if (!moderatorFile && !adText && !buttonUrl && !ordCategory) { + throw new Error('Пересдача: нужна хоть одна правка — документ модератору (moderatorFile) или новый текст/ссылка/категория ОРД'); } - return { mode: 'resubmit', campaignId, submitMode, moderatorFile, adText, adHeadline, buttonUrl, mediaFile, ordCategory }; + return { mode: 'resubmit', campaignId, submitMode, moderatorFile, adText, buttonUrl, ordCategory }; } export function parseTask(raw) { diff --git a/bots/mts-telegram-ads/src/trevoga.js b/bots/mts-telegram-ads/src/trevoga.js new file mode 100644 index 00000000..816fc25c --- /dev/null +++ b/bots/mts-telegram-ads/src/trevoga.js @@ -0,0 +1,31 @@ +// Отправка письма-тревоги, которая не умеет молчать. +// +// 🔴 Поймано 06.08.2026. В стороже сессии стояло `catch {}` вокруг отправки письма — +// голое, без единой строчки. Отправитель `bot@liderra.ru` не существует, Яндекс отбивал +// каждое письмо («553 Sender address rejected: user not found»), и этот отказ уходил +// в никуда. Сторож двое суток честно ловил мёртвый кабинет — 71 раз подряд — и владелец +// не узнал об этом ни разу. +// +// Правило теперь такое: почта упала — след в журнале обязателен. Но сама тревога от +// этого не срывается: сторож и без письма пишет причину и отдаёт код выхода. +export async function otpravitTrevogu({ mailer, log = console.error }, pismo) { + return poprobovat(log, 'тревоги', () => mailer.alarm(pismo)); +} + +// Письмо-отчёт об удачном запуске. Та же немая ловушка стояла в `runner.js`: сбой +// отправки съедался, и «письма не приходят» выглядело загадкой без единой зацепки. +export async function otpravitOtchyot({ mailer, log = console.error }, pismo) { + return poprobovat(log, 'отчёта', () => mailer.report(pismo)); +} + +async function poprobovat(log, chto, deystvie) { + try { + await deystvie(); + return true; + } catch (e) { + // Слово «письмо» в строке — не украшение: без него читающий журнал решит, + // что сломался кабинет, а сломалась почта. + log(`письмо ${chto} НЕ ушло: ${e?.message ?? e}`); + return false; + } +} diff --git a/bots/mts-telegram-ads/test/data-starta-shov.test.mjs b/bots/mts-telegram-ads/test/data-starta-shov.test.mjs new file mode 100644 index 00000000..8bce0efe --- /dev/null +++ b/bots/mts-telegram-ads/test/data-starta-shov.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readModerationStatus } from '../src/cabinet.js'; + +// 🪤 Шов, а не половинка. Разбор даты может быть исправен, а наверх не уезжать ничего — +// ровно так уже было со словом «Запланирована»: разбор починили, а поиск ряда остался +// мёртвым, и починка выглядела сделанной. Поэтому проверяем ЦЕЛИКОМ ту функцию, которую +// зовёт робот: подсовываем ей строку кабинета и смотрим, что она вернула. +// +// 🔑 Строка — настоящая, снята живьём 06.08.2026 с кампании 2237821. +const ZHIVOY_RYAD = '07.08 Telegram по своей базе Отменить запуск Запланирована Каналы Свой список 400 ₽ 182,4 ₽'; + +const config = { telegramUrl: 'https://кабинет/campaigns/telegram', humanDelayMs: 0 }; + +/** Страница-подделка: отрисовалась, а на вопрос о ряде отдаёт заданный текст. */ +function stranica(rowText) { + return { + goto: async () => {}, + reload: async () => {}, + waitForTimeout: async () => {}, + // Длиннее порога «страница ожила» (50 знаков) — иначе gotoStable сочтёт кабинет + // зависшим на пустой крутилке и до чтения ряда дело не дойдёт. + locator: () => ({ innerText: async () => 'страница кабинета отрисована полностью, тело заведомо длиннее порога' }), + evaluate: async () => rowText, + }; +} + +test('вместе с вердиктом наверх уезжает и дата старта', async () => { + const itog = await readModerationStatus( + stranica(ZHIVOY_RYAD), config, '2237821', new Date(2026, 7, 6), + ); + + assert.equal(itog.moderationStatus, 'moderating'); + assert.equal(itog.startDate, '2026-08-07', 'дата старта обязана доехать до портала'); +}); + +test('нет даты в строке — вердикт всё равно читается', async () => { + const itog = await readModerationStatus( + stranica('Моя кампания На модерации Посмотреть настройки'), config, '2237821', new Date(2026, 7, 6), + ); + + assert.equal(itog.moderationStatus, 'moderating'); + assert.equal(itog.startDate, null, 'даты нет — это не беда, а просто null'); +}); diff --git a/bots/mts-telegram-ads/test/data-starta.test.mjs b/bots/mts-telegram-ads/test/data-starta.test.mjs new file mode 100644 index 00000000..aa9ecbf8 --- /dev/null +++ b/bots/mts-telegram-ads/test/data-starta.test.mjs @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseStartDate } from '../src/cabinet.js'; + +// 🔴 Зачем это вообще. Портал не передаёт кабинету НИ ОДНОЙ даты — их ставит сам +// кабинет своими умолчаниями. Экран обещает клиенту «7 дней», подразумевая старт +// сегодня, а живьём (кампания 2237821, 06.08.2026) старт оказался завтрашним. Клиент +// ждёт показов сегодня и не понимает, почему их нет. +// +// Решение владельца — самое дешёвое: не давать выбирать даты, а ПОКАЗАТЬ ту, что +// поставил кабинет. Робот и так читает строку кампании в списке раз в час; дата +// стоит в её начале. Ни одного лишнего захода в кабинет. +// +// 🔑 Строка ниже — НЕ выдумка: это настоящий текст ряда кампании 2237821, снятый +// живьём 06.08.2026 (он же лежит в test/zaplanirovana.test.mjs). Начинается с 07.08 — +// ровно та дата старта, которую смена независимо увидела в карточке кампании. +const ZHIVOY_RYAD = '07.08 Telegram по своей базе Отменить запуск Запланирована Каналы Свой список 400 ₽ 182,4 ₽'; + +const SHESTOE_AVGUSTA = new Date(2026, 7, 6); + +test('живая строка кабинета отдаёт дату старта', () => { + assert.equal(parseStartDate(ZHIVOY_RYAD, SHESTOE_AVGUSTA), '2026-08-07'); +}); + +test('дата с годом читается как есть', () => { + assert.equal(parseStartDate('07.08.2026 Запланирована', SHESTOE_AVGUSTA), '2026-08-07'); +}); + +// Кабинет может показать период показа. Нас интересует НАЧАЛО — первая дата. +test('из периода берём начало, а не конец', () => { + assert.equal(parseStartDate('07.08 – 14.08 Запланирована', SHESTOE_AVGUSTA), '2026-08-07'); +}); + +// 🪤 Главная ловушка: в той же строке стоят деньги. Прими робот «12.05 ₽» за дату — +// клиенту показали бы 12 мая как день старта рекламы, и выглядело бы это правдой. +// Кабинет пишет суммы через ЗАПЯТУЮ («182,4 ₽»), но полагаться на это одно нельзя. +test('деньги не выдают себя за дату', () => { + assert.equal(parseStartDate('Одобрена 12.05 ₽ Каналы', SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate('Каналы Свой список 400 ₽ 182,4 ₽', SHESTOE_AVGUSTA), null); +}); + +// Кабинет пишет день и месяц ВСЕГДА двумя цифрами. Короткая запись — не дата, а +// что-то другое (версия, доля, обрывок). +test('одноцифровая запись датой не считается', () => { + assert.equal(parseStartDate('7.8 Запланирована', SHESTOE_AVGUSTA), null); +}); + +test('невозможные день и месяц отбрасываются', () => { + assert.equal(parseStartDate('45.13 Запланирована', SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate('00.00 Запланирована', SHESTOE_AVGUSTA), null); + // 31 февраля не бывает — кабинет такого не напишет, значит это не дата. + assert.equal(parseStartDate('31.02 Запланирована', SHESTOE_AVGUSTA), null); +}); + +// 🪤 Год в строке не указан. В конце декабря «02.01» — это СЛЕДУЮЩИЙ год: кампания +// стартует после Нового года. Подставить текущий значило бы показать клиенту дату +// на год в прошлом. +test('на переломе года дата уезжает в следующий год', () => { + assert.equal(parseStartDate('02.01 Запланирована', new Date(2026, 11, 31)), '2027-01-02'); +}); + +// Обратная сторона: кампания, начавшаяся в декабре, в первых числах января ещё +// показывается в списке. Её дата — ПРОШЛЫЙ год, а не будущий. +test('в начале января недавнее прошлое остаётся прошлым годом', () => { + assert.equal(parseStartDate('28.12 Активна', new Date(2027, 0, 3)), '2026-12-28'); +}); + +// Дата — вещь необязательная: её отсутствие не должно ронять чтение вердикта. +// Вердикт и деньги важнее даты, и ни один сбой разбора не имеет права им помешать. +test('нет даты, пусто и не строка — просто null, без падения', () => { + assert.equal(parseStartDate('Запланирована Каналы', SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate('', SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate(null, SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate(undefined, SHESTOE_AVGUSTA), null); + assert.equal(parseStartDate(12345, SHESTOE_AVGUSTA), null); +}); diff --git a/bots/mts-telegram-ads/test/keepalive-plan.test.js b/bots/mts-telegram-ads/test/keepalive-plan.test.js index 79535e1c..bf5d36f3 100644 --- a/bots/mts-telegram-ads/test/keepalive-plan.test.js +++ b/bots/mts-telegram-ads/test/keepalive-plan.test.js @@ -2,19 +2,21 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { keepaliveBrowserOpts } from '../src/keepalive-plan.js'; -import { BLOCK_MESSAGE } from '../src/session.js'; +import { blockMessage } from '../src/session.js'; -// Сторож живой сессии (bin/keepalive.js) поднимал браузер БЕЗ ОКНА (`headless: true`), -// а настоящая работа робота ходит С ОКНОМ (умолчание openBrowser). Это два разных -// браузера с точки зрения защиты МТС, и сторож ловил отказ там, где работа проходила. +// 🔴🔴🔴 СТОРОЖ ПРОТИВ ОТКАТА. Правка 07.08.2026, разрешена владельцем. // -// 🔴 Замер 07.08.2026, одна ось, два порядка, один профиль и один адрес: +// Сторож живой сессии (bin/keepalive.js) поднимал браузер БЕЗ ОКНА (`headless: true`), +// а настоящая работа робота ходит С ОКНОМ (умолчание openBrowser). Для защиты МТС это +// два разных браузера: сторож ловил отказ 403 там, где работа проходила, и владелец +// получал письмо КАЖДЫЙ ЧАС о беде, которой не было. +// +// Замер, одна ось, два порядка, тот же профиль и тот же адрес: // сперва без окна → без окна НЕ ПУСКАЕТ, следом с окном ПУСКАЕТ; // сперва с окном → пускает и то, и другое. -// То есть живой заход обновляет пропуск в профиле, а безоконный сам получить его не -// может. Журнал службы сходится до минуты: последнее «жив» в 10:40 МСК — сразу после -// последнего задания в 10:30; заданий не стало, пропуск протух, и с 11:45 пошли отказы -// подряд. Владелец получал письмо каждый час о беде, которой у работы не было. +// +// Эти тесты стоят здесь ИМЕННО ЗАТЕМ, чтобы починку не стёрли молча: любой откат к +// безоконному браузеру или к совету «смени адрес выхода» красит `npm test`. test('сторож открывает браузер ТОЧНО так же, как настоящая работа робота', () => { // Настоящая работа зовёт openBrowser(config, {}) — см. runner.js. Любое расхождение @@ -28,7 +30,7 @@ test('сторож не просит браузер без окна', () => { test('🔴 сам bin/keepalive.js не просит браузер без окна', () => { // Проверка по тексту файла нарочно: план можно оставить правильным, а в сторожа - // вписать `headless` прямо — и болезнь вернётся мимо остальных проверок. + // вписать `{ headless: true }` прямо — и болезнь вернётся мимо остальных проверок. // Комментарии выкусываем: они НАЗЫВАЮТ запрещённое, чтобы предупредить о нём, и // сторож ловил бы собственное предупреждение вместо настоящего отката. const kod = readFileSync(new URL('../bin/keepalive.js', import.meta.url), 'utf8') @@ -39,13 +41,11 @@ test('🔴 сам bin/keepalive.js не просит браузер без ок }); test('🔴 отказ кабинета больше НЕ советует менять адрес выхода', () => { - // Прежний текст: «МТС не пускает этот адрес — роботу нужен другой адрес выхода». - // Совет ошибочный и дорогой: смена 07.08.2026 потратила разбор на поиск блокировки - // адреса, а адрес был один и тот же и не заблокирован (замеры в - // docs/superpowers/2026-08-07-PROMT-mts-bot-kabinet-otbivaet-brauzer.md). - // Запрещаем именно СОВЕТ, а не упоминание: новый текст называет адрес нарочно — - // чтобы отвести от него следующего разбирающего, а не чтобы послать его туда. - assert.doesNotMatch(BLOCK_MESSAGE, /нужен другой адрес/i); - assert.match(BLOCK_MESSAGE, /ни при чём/i); - assert.match(BLOCK_MESSAGE, /вход(ом)? руками|npm run login/i); + // Прежний текст: «…роботу нужен другой адрес выхода». Совет ошибочный: адрес был один + // и тот же и не заблокирован. Запрещаем именно СОВЕТ, а не упоминание — новый текст + // называет адрес нарочно, чтобы отвести от него следующего разбирающего. + const text = blockMessage(3); + assert.doesNotMatch(text, /нужен другой адрес/i); + assert.match(text, /ни при чём/i); + assert.match(text, /вход(ом)? руками|npm run login/i); }); diff --git a/bots/mts-telegram-ads/test/moderation-hints.test.js b/bots/mts-telegram-ads/test/moderation-hints.test.js new file mode 100644 index 00000000..0fae141b --- /dev/null +++ b/bots/mts-telegram-ads/test/moderation-hints.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isModerationHintsText, skipModerationHints } from '../src/cabinet.js'; + +// 🔑 Живой прогон 06.08.2026, кампания МТС 2237812: после «Продолжить» на шаге +// «Объявление» кабинет показывает окно-заслонку «Отметили места, из-за которых +// рекламу могут отклонить — лучше сразу их поправить» с кнопками «ПРОПУСТИТЬ» и +// «ПОСМОТРЕТЬ КОММЕНТАРИИ». Робот про него не знал: ждал перехода на «Стоимость», +// не дожидался и падал невнятным «не перешли на /budget». Кампания при этом +// оставалась недоделанной в кабинете, а деньги клиента — замороженными. + +const TEKST_OKNA = 'Отметили места, из-за которых рекламу могут отклонить — лучше сразу их поправить ПРОПУСТИТЬ ПОСМОТРЕТЬ КОММЕНТАРИИ'; + +test('текст окна замечаний опознаётся', () => { + assert.equal(isModerationHintsText(TEKST_OKNA), true); +}); + +test('обычная страница шага за окно замечаний не принимается', () => { + assert.equal(isModerationHintsText('Текст 0 / 160 Ссылка Что рекламируете ПРОДОЛЖИТЬ'), false); + assert.equal(isModerationHintsText(''), false); + assert.equal(isModerationHintsText(null), false); +}); + +test('окна нет — ничего не жмём', async () => { + const nazhali = []; + const bylo = await skipModerationHints({}, { + bodyText: async () => 'обычная страница шага', + click: async (_p, re) => { nazhali.push(re.source); return 'Пропустить'; }, + }); + + assert.equal(bylo, false); + assert.deepEqual(nazhali, [], 'кнопки трогать было незачем'); +}); + +test('окно есть — жмём «Пропустить» и говорим, что закрыли', async () => { + const nazhali = []; + const bylo = await skipModerationHints({}, { + bodyText: async () => TEKST_OKNA, + click: async (_p, re) => { nazhali.push(re.source); return 'ПРОПУСТИТЬ'; }, + }); + + assert.equal(bylo, true); + assert.equal(nazhali.length, 1, 'ровно одно нажатие'); + assert.match('пропустить', new RegExp(nazhali[0], 'i'), 'жмём именно «Пропустить»'); +}); + +test('окно есть, а кнопки «Пропустить» в нём нет — падаем своими словами', async () => { + await assert.rejects( + () => skipModerationHints({}, { + bodyText: async () => TEKST_OKNA, + click: async () => null, + }), + (e) => { + assert.match(e.message, /Пропустить/i); + return true; + }, + ); +}); + +test('кнопка «Пропустить» неактивна — тоже падаем, а не врём про успех', async () => { + await assert.rejects( + () => skipModerationHints({}, { + bodyText: async () => TEKST_OKNA, + click: async () => 'disabled', + }), + (e) => { + assert.match(e.message, /неактивна/i); + return true; + }, + ); +}); diff --git a/bots/mts-telegram-ads/test/oplata-gejt.test.js b/bots/mts-telegram-ads/test/oplata-gejt.test.js new file mode 100644 index 00000000..8e0ceb21 --- /dev/null +++ b/bots/mts-telegram-ads/test/oplata-gejt.test.js @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertPayable, payCapRub } from '../src/cabinet.js'; + +/** + * Карточки приёмки Т-Р3 (потолок траты держит) и Т-Р4 (сумма на кассе не прочиталась). + * + * 🔴 Это ЕДИНСТВЕННОЕ место, где робот тратит живые деньги с баланса кабинета МТС. + * Живьём эти карточки не воспроизвести: чтобы увидеть нечитаемую сумму, надо ломать + * страницу кабинета подменой, а чтобы увидеть отказ по потолку — заводить заведомо + * дорогую кампанию и подходить с ней к кассе. Ранбук прямо разрешает закрыть их тестом. + * + * 🪤 Найдено 06.08.2026: обе защиты жили БЕЗ ЕДИНОГО ТЕСТА. Любая правка рядом могла + * снять их молча, и узнали бы мы об этом по счёту от МТС. + */ + +// ── Т-Р4: не прочитали сумму — не платим ────────────────────────────────────── +// +// «Заплатить наугад» здесь означает списать с баланса неизвестно сколько. Отказ — +// единственное правильное поведение: кампания упадёт, деньги клиента вернутся. + +test('Т-Р4: сумма не прочиталась (null) — платить запрещено', () => { + assert.throws(() => assertPayable(null, 1000), /не прочитана/i); +}); + +test('Т-Р4: сумма не прочиталась (пусто, undefined, буквы) — платить запрещено', () => { + assert.throws(() => assertPayable(undefined, 1000), /не прочитана/i); + assert.throws(() => assertPayable('', 1000), /не прочитана/i); + assert.throws(() => assertPayable('н/д', 1000), /не прочитана/i); + assert.throws(() => assertPayable(NaN, 1000), /не прочитана/i); +}); + +test('Т-Р4: прочитанная сумма под потолком — платить можно', () => { + assert.doesNotThrow(() => assertPayable('182.40', 1000)); + assert.doesNotThrow(() => assertPayable(182.4, 1000)); +}); + +// ── Т-Р3: потолок траты держит ──────────────────────────────────────────────── + +test('Т-Р3: сумма выше потолка — платить запрещено', () => { + assert.throws(() => assertPayable('1200.00', 1000), /превышает потолок/i); +}); + +test('Т-Р3: сумма ровно в потолок — платить можно', () => { + assert.doesNotThrow(() => assertPayable('1000.00', 1000)); +}); + +// 🔴 Потолков ДВА: лимит самой кампании (его задал клиент) и общий потолок робота +// (решение владельца, 1 000 ₽). Побеждать обязан МЕНЬШИЙ. Возьми код больший — робот +// потратил бы сверх того, что разрешил хотя бы один из двух. +test('Т-Р3: из двух потолков побеждает МЕНЬШИЙ — лимит кампании', () => { + assert.equal(payCapRub(500, 1000), 500); +}); + +test('Т-Р3: из двух потолков побеждает МЕНЬШИЙ — общий потолок робота', () => { + assert.equal(payCapRub(5000, 1000), 1000); +}); + +test('Т-Р3: кампания дороже своего лимита — сквозь оба потолка не проходит', () => { + const cap = payCapRub(500, 1000); + assert.throws(() => assertPayable('600.00', cap), /превышает потолок/i); +}); + +// 🪤 «Потолка нет» ни при каких обстоятельствах не должно значить «плати сколько +// скажут»: это ровно то, чего владелец не разрешал. +test('Т-Р3: потолок не задан или бессмысленный — платить запрещено', () => { + for (const durnoy of [undefined, null, 0, -1, NaN, 'много', '']) { + assert.throws(() => payCapRub(500, durnoy), /потолок/i, `общий потолок ${durnoy}`); + assert.throws(() => payCapRub(durnoy, 1000), /потолок/i, `лимит кампании ${durnoy}`); + } +}); + +test('Т-Р3: гейт оплаты сам не верит бессмысленному потолку', () => { + assert.throws(() => assertPayable('100.00', 0), /потолок/i); + assert.throws(() => assertPayable('100.00', NaN), /потолок/i); +}); diff --git a/bots/mts-telegram-ads/test/poll-modes.test.js b/bots/mts-telegram-ads/test/poll-modes.test.js index 845d3194..fea8cd77 100644 --- a/bots/mts-telegram-ads/test/poll-modes.test.js +++ b/bots/mts-telegram-ads/test/poll-modes.test.js @@ -13,10 +13,6 @@ test('poll: пересдаче номера не нужны', () => { assert.deepEqual(planForJob({ mode: 'resubmit' }), { needsPhones: false, kind: 'resubmit' }); }); -test('poll: чтению статистики номера не нужны — цифры лежат в списке кабинета', () => { - assert.deepEqual(planForJob({ mode: 'read-stats' }), { needsPhones: false, kind: 'read-stats' }); -}); - test('poll: запуску номера нужны — и в бою, и в песочнице', () => { assert.deepEqual(planForJob({ mode: 'live' }), { needsPhones: true, kind: 'run' }); assert.deepEqual(planForJob({ mode: 'draft' }), { needsPhones: true, kind: 'run' }); diff --git a/bots/mts-telegram-ads/test/read-status.test.mjs b/bots/mts-telegram-ads/test/read-status.test.mjs index 6b797a75..b6a4b2ba 100644 --- a/bots/mts-telegram-ads/test/read-status.test.mjs +++ b/bots/mts-telegram-ads/test/read-status.test.mjs @@ -26,14 +26,6 @@ test('«Активна» (запущена после модерации) → ap assert.equal(parseModerationStatus('Активна'), 'approved'); }); -test('🔴 «Запущена» → approved — так кабинет ВПРАВДУ пишет у работающей кампании', () => { - // Живой замер 07.08.2026 (кампания 2237821): в списке стоит именно «Запущена», - // а не «Активна». Робот такого слова не знал → статус не определялся → опросчик - // модерации бросал ошибку и оставлял кампанию «на проверке» НАВСЕГДА, хотя реклама - // уже шла и деньги площадки уже тратились. - assert.equal(parseModerationStatus('Запущена'), 'approved'); -}); - test('«Черновик» → draft', () => { assert.equal(parseModerationStatus('Черновик'), 'draft'); }); diff --git a/bots/mts-telegram-ads/test/resubmit-task.test.js b/bots/mts-telegram-ads/test/resubmit-task.test.js index d80457a6..31334095 100644 --- a/bots/mts-telegram-ads/test/resubmit-task.test.js +++ b/bots/mts-telegram-ads/test/resubmit-task.test.js @@ -80,34 +80,3 @@ test('parseResubmitTask НЕ требует phonesFile/budgetRub (аудитор assert.equal(t.phonesFile, undefined); assert.equal(t.budgetRub, undefined); }); - -// 🔴 П‑9 спеки 07.08.2026 «клиент перестаёт быть слепым». Проверка ругается на КАРТИНКУ и -// ЗАГОЛОВОК не реже, чем на текст («замените изображение», «уберите восклицательный знак в -// заголовке»), а пересдача этих двух полей не принимала вовсе: клиент отправлял ту же -// картинку и получал тот же отказ, сжигая ещё один четырёхчасовой круг проверки. -test('parseResubmitTask пробрасывает заголовок и медиа (П‑9)', () => { - const t = parseResubmitTask({ ...base, adHeadline: 'Новый заголовок', mediaFile: 'C:/tmp/ad.jpg' }); - assert.equal(t.adHeadline, 'Новый заголовок'); - assert.equal(t.mediaFile, 'C:/tmp/ad.jpg'); -}); - -test('parseResubmitTask: одного нового заголовка достаточно — это уже правка', () => { - const t = parseResubmitTask({ - mode: 'resubmit', campaignId: '2231134', submitMode: 'draft', adHeadline: 'Только заголовок', - }); - assert.equal(t.adHeadline, 'Только заголовок'); - assert.equal(t.moderatorFile, null); -}); - -test('parseResubmitTask: одной новой картинки достаточно — это уже правка', () => { - const t = parseResubmitTask({ - mode: 'resubmit', campaignId: '2231134', submitMode: 'draft', mediaFile: 'C:/tmp/ad.jpg', - }); - assert.equal(t.mediaFile, 'C:/tmp/ad.jpg'); -}); - -test('parseResubmitTask: незаданные заголовок и медиа → null, прежние остаются в кабинете', () => { - const t = parseResubmitTask(base); - assert.equal(t.adHeadline, null); - assert.equal(t.mediaFile, null); -}); diff --git a/bots/mts-telegram-ads/test/session-retry.test.js b/bots/mts-telegram-ads/test/session-retry.test.js index 76c91a63..3af912cf 100644 --- a/bots/mts-telegram-ads/test/session-retry.test.js +++ b/bots/mts-telegram-ads/test/session-retry.test.js @@ -72,7 +72,26 @@ test('настоящий разлогин — сразу и БЕЗ повтор assert.deepEqual(pauses, [], 'ждать было незачем'); }); -test('МТС не пускает адрес — сразу и без повторов, со своим объяснением', async () => { +// 🔑 Приёмка 06.08.2026: отказ по адресу ПЕРЕМЕЖАЕТСЯ. За четверть часа один и тот же +// адрес дал «доступ запрещён» дважды подряд, а через пять минут — дважды пустил, без +// каких-либо изменений на нашей стороне. Прежнее поведение (сдаться на первом отказе, +// retryable=false) 36 часов прятало настоящую причину простоя: слетевший вход. +// Поэтому отказ по адресу лечим повтором, как зависшую страницу. + +test('отказ по адресу перемежается — со второй попытки проходим', async () => { + let calls = 0; + const check = async () => { calls += 1; return calls >= 2 ? 'in' : 'zablokirovan'; }; + const pauses = []; + + const used = await ensureLoggedIn(page, config, { + check, attempts: 3, pauseMs: 5000, sleep: async (ms) => { pauses.push(ms); }, + }); + + assert.equal(used, 2, 'первый отказ — не приговор'); + assert.deepEqual(pauses, [5000], 'между попытками ровно одна пауза'); +}); + +test('отказ по адресу держится ВСЕ попытки — тогда говорим про адрес', async () => { let calls = 0; const check = async () => { calls += 1; return 'zablokirovan'; }; @@ -80,13 +99,35 @@ test('МТС не пускает адрес — сразу и без повто () => ensureLoggedIn(page, config, { check, attempts: 3, pauseMs: 1, sleep: async () => {} }), (e) => { assert.ok(e instanceof CabinetBlockedError); - assert.equal(e.retryable, false); + assert.equal(e.retryable, true, 'повтор помогает — задание должно вернуться в очередь'); assert.match(e.message, /адрес/i); + assert.match(e.message, /подряд/i, 'сказать, что отказ был не разовый'); return true; }, ); - assert.equal(calls, 1); + assert.equal(calls, 3, 'использованы все попытки, а не одна'); +}); + +test('отказ по адресу не выдаёт себя за разлогин', async () => { + const check = async () => 'zablokirovan'; + + await assert.rejects( + () => ensureLoggedIn(page, config, { check, attempts: 2, pauseMs: 1, sleep: async () => {} }), + (e) => { + // 🔴 Ослаблено 07.08.2026 по замеру, разрешено владельцем. Прежде тут стояло + // `doesNotMatch(e.message, /руками|вход/i)` с доводом «вход тут ни при чём». + // Довод оказался неверен: отказ 403 лечится ИМЕННО живым входом — он обновляет + // пропуск в профиле браузера (замер: безоконный браузер не пускают, следом заход + // с окном проходит, и после него безоконный тоже проходит). + // + // Настоящая защита сторожа сохранена: отказ по адресу НЕ имеет права выдавать + // себя за потерянный вход — иначе разбирающий пойдёт чинить не то. + assert.doesNotMatch(e.message, /потерян|слет(ел|ела)/i, 'это не разлогин'); + assert.match(e.message, /не пустил|не пускает/i, 'сказать прямо, что это отказ кабинета'); + return true; + }, + ); }); test('сбой самой проверки — считаем зависанием, а не разлогином', async () => { diff --git a/bots/mts-telegram-ads/test/sluzhba-plan.test.mjs b/bots/mts-telegram-ads/test/sluzhba-plan.test.mjs new file mode 100644 index 00000000..8f06b203 --- /dev/null +++ b/bots/mts-telegram-ads/test/sluzhba-plan.test.mjs @@ -0,0 +1,113 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sleduyushchiyShag, MINUTA, CHAS } from '../src/sluzhba-plan.js'; + +// 🔴 Зачем служба вообще появилась. Обе задачи Планировщика ходили с типом входа S4U — +// «вход без пароля». У такого входа нет ключей, которыми браузер расшифровывает +// сохранённые печенья профиля, поэтому каждый заход видел мёртвый кабинет. Замерено +// 06.08.2026: заход Планировщика в 12:24 сказал «вход потерян», а три захода руками +// в 13:3x прошли нормально, и никто заново не входил. Значит S4U вход НЕ ломает — он +// его просто НЕ ВИДИТ. Лечение: работать не задачей по расписанию, а вечной петлёй +// внутри живого сеанса владельца, где профиль браузера читается. +// +// 🔴 Главное правило петли: профиль браузера ОДИН на всех. Робот и сторож ходят в него +// же. Два захода разом — это драка за профиль, а прежняя защита от неё жила в самом +// Планировщике (`MultipleInstances = IgnoreNew`). Уходя с Планировщика, эту защиту +// надо унести с собой, иначе служба своими руками сделает то, от чего Планировщик берёг. + +const T0 = 1_000_000_000_000; // любая точка отсчёта: часы в решении не участвуют + +test('пока заход идёт — ждём, даже если всё созрело', () => { + const shag = sleduyushchiyShag({ + idyot: true, + poslednijPoll: T0, + poslednijKeepalive: T0, + seychas: T0 + CHAS * 5, + }); + + assert.equal(shag, 'zhdat'); +}); + +test('минута прошла — идём за работой', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: T0, + poslednijKeepalive: T0, + seychas: T0 + MINUTA, + }); + + assert.equal(shag, 'poll'); +}); + +test('минута ещё не прошла — ждём, кабинет зря не дёргаем', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: T0, + poslednijKeepalive: T0, + seychas: T0 + MINUTA - 1, + }); + + assert.equal(shag, 'zhdat'); +}); + +// Если вход в кабинет умер, заход за работой всё равно упрётся в мёртвую сессию и +// потратит попытку. Сторож дешевле и отвечает на главный вопрос: жив ли вход вообще. +test('когда созрели оба — сначала проверяем вход, потом работу', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: T0, + poslednijKeepalive: T0, + seychas: T0 + CHAS, + }); + + assert.equal(shag, 'keepalive'); +}); + +test('час не прошёл — сторожа не гоняем', () => { + const seychas = T0 + CHAS - 1; + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: seychas, // работу только что спрашивали, иначе созрела бы она + poslednijKeepalive: T0, + seychas, + }); + + assert.equal(shag, 'zhdat'); +}); + +// 🪤 Первый запуск: ничего ещё не делали. Ждать час до первой проверки входа нельзя — +// служба должна сразу сказать, жив ли кабинет, иначе владелец узнает об этом через час. +test('на первом запуске сразу проверяем вход', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: null, + poslednijKeepalive: null, + seychas: T0, + }); + + assert.equal(shag, 'keepalive'); +}); + +test('вход только что проверен, работы ещё не спрашивали — спрашиваем', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: null, + poslednijKeepalive: T0, + seychas: T0, + }); + + assert.equal(shag, 'poll'); +}); + +// 🪤 Часы машины могут прыгнуть назад: перевод времени, синхронизация с сервером. +// Отрицательная разница не должна превратиться в «ещё не пора» на сутки вперёд. +test('часы прыгнули назад — служба не засыпает навсегда', () => { + const shag = sleduyushchiyShag({ + idyot: false, + poslednijPoll: T0 + CHAS, + poslednijKeepalive: T0 + CHAS, + seychas: T0, + }); + + assert.equal(shag, 'keepalive'); +}); diff --git a/bots/mts-telegram-ads/test/sluzhba-profil-zanyat.test.mjs b/bots/mts-telegram-ads/test/sluzhba-profil-zanyat.test.mjs new file mode 100644 index 00000000..3a8ebe7e --- /dev/null +++ b/bots/mts-telegram-ads/test/sluzhba-profil-zanyat.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { etoNashProfil } from '../src/sluzhba-plan.js'; + +// 🔴 Живой случай 06.08.2026, вечер. Владелец: «вылетает вход в кабинет, но я не успеваю +// войти — окно закрывается». Причина замерена командой, а не угадана. +// +// Профиль браузера ОДИН на троих: робот, сторож и окно ручного входа владельца. Служба +// перед каждым заходом обязана спросить «профиль занят?» и пропустить ход, если занят. +// Спрашивала она так: ищи среди процессов браузера тот, в чьей командной строке есть +// `C:\liderra\mts-bot-profile` — путь ОБРАТНЫМИ косыми чертами. +// +// А браузер подписывается тем путём, каким он записан в настройках, то есть ПРЯМЫМИ: +// замер 06.08.2026 22:22 — 7 процессов с прямыми против 1 случайного вспомогательного +// с обратными. Сравнение буква в букву не совпадало, служба считала профиль свободным +// и поднимала свой браузер прямо поверх открытого окна входа. Второй браузер на том же +// профиле не встаёт — окно владельца схлопывалось. +// +// 🔑 Улика, что защита не срабатывала НИ РАЗУ: за всё время жизни службы в журнале нет +// ни одной строки «ПРОПУСК: профиль занят». +// +// Поэтому пути сравниваем по смыслу, а не по буквам: вид косой черты и регистр — это +// один и тот же путь. + +const PROFIL = 'C:\\liderra\\mts-bot-profile'; + +test('прямые косые черты — это тот же профиль (тот самый живой случай)', () => { + const stroka = '"C:\\Program Files\\Microsoft\\Edge\\msedge.exe" --user-data-dir=C:/liderra/mts-bot-profile --no-proxy-server'; + + assert.equal(etoNashProfil(stroka, PROFIL), true); +}); + +test('обратные косые черты — тоже тот же профиль', () => { + const stroka = 'msedge.exe --user-data-dir=C:\\liderra\\mts-bot-profile /prefetch:4'; + + assert.equal(etoNashProfil(stroka, PROFIL), true); +}); + +test('другой регистр буквы диска — всё ещё тот же профиль', () => { + const stroka = 'msedge.exe --user-data-dir=c:/LIDERRA/MTS-BOT-PROFILE'; + + assert.equal(etoNashProfil(stroka, PROFIL), true); +}); + +test('чужой профиль занятым не считается — иначе служба уснёт навсегда', () => { + const stroka = 'chrome.exe --user-data-dir=C:/Users/Administrator/AppData/Local/Google/Chrome/User Data'; + + assert.equal(etoNashProfil(stroka, PROFIL), false); +}); + +test('пустая командная строка — не повод считать профиль занятым', () => { + assert.equal(etoNashProfil('', PROFIL), false); + assert.equal(etoNashProfil(null, PROFIL), false); + assert.equal(etoNashProfil(undefined, PROFIL), false); +}); diff --git a/bots/mts-telegram-ads/test/sluzhba-zamok.test.mjs b/bots/mts-telegram-ads/test/sluzhba-zamok.test.mjs new file mode 100644 index 00000000..4da51af0 --- /dev/null +++ b/bots/mts-telegram-ads/test/sluzhba-zamok.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { reshenieZamka } from '../src/sluzhba-plan.js'; + +// 🔴 Профиль браузера ОДИН на робота, сторожа и окно ручного входа. Пока работу +// раздавал Планировщик, от двух экземпляров берёг он сам (`MultipleInstances = +// IgnoreNew`). Служба такой защиты не имеет вовсе: запусти её дважды — и два +// экземпляра полезут в кабинет одновременно, каждый со своим счётчиком «идёт заход». +// Итог был бы тот же, от которого Планировщик берёг: драка за профиль. +// +// 🪤 Простой замок «есть файл — не пускать» ломает всё при первом же падении: файл +// останется от мёртвого процесса, и служба больше никогда не поднимется. Поэтому +// решение принимается ПО ДВУМ вещам: есть ли запись и жив ли записанный в ней номер. + +test('замка нет — путь свободен', () => { + assert.equal(reshenieZamka({ zapis: null, zhiv: false }), 'svobodno'); +}); + +test('замок держит живой процесс — второй экземпляр не запускается', () => { + assert.equal(reshenieZamka({ zapis: '4242', zhiv: true }), 'zanyat'); +}); + +// Служба упала или машину выключили кнопкой — файл остался, процесса нет. +test('замок от мёртвого процесса перехватываем, а не сдаёмся навсегда', () => { + assert.equal(reshenieZamka({ zapis: '4242', zhiv: false }), 'perehvat'); +}); + +// 🪤 Битая запись — это тоже «никто не держит». Считать её живым замком значило бы +// запереть службу навсегда из-за одного оборванного при записи файла. +test('запись в замке битая — перехватываем', () => { + assert.equal(reshenieZamka({ zapis: 'не число', zhiv: false }), 'perehvat'); + assert.equal(reshenieZamka({ zapis: '', zhiv: false }), 'perehvat'); +}); diff --git a/bots/mts-telegram-ads/test/stats-row.test.js b/bots/mts-telegram-ads/test/stats-row.test.js index efb0c0c7..e6a6b1e0 100644 --- a/bots/mts-telegram-ads/test/stats-row.test.js +++ b/bots/mts-telegram-ads/test/stats-row.test.js @@ -2,16 +2,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseStatsRow } from '../src/cabinet.js'; -// П‑7 (спека 2026-08-07-telegram-otchyot-i-upravlenie): робот читает показы/клики/ -// подписчиков запущенной кампании из строки списка кабинета. +// П‑7 (07.08.2026): робот читает показы/клики/подписчиков запущенной кампании из +// строки списка кабинета. // // Почему разбираем С КОНЦА. Число ведущих кусков строки ПЛАВАЕТ: у запущенной // кампании два действия («Завершить», «Посмотреть настройки»), у черновика одно, // у отклонённой три (+«Причины»). А хвост постоянен: последние 12 кусков — ровно -// 12 колонок метрик, от «Показы» до «Общий расход» (снято живьём 07.08.2026, -// см. docs/cabinet-flow.md §8a). +// 12 колонок метрик, от «Показы» до «Общий расход». -// Живой ряд кампании 2237821 (07.08.2026, статус «Запущена») — байт в байт из разведки. +// Живой ряд кампании 2237821 (07.08.2026, «Запущена») — байт в байт из разведки. const ZAPUSHCHENNAYA = [ '07.08 Telegram по своей базе', 'Завершить', 'Посмотреть настройки', 'Запущена', 'Каналы', 'Свой список', '400 ₽', '182,4 ₽', @@ -34,8 +33,8 @@ test('читает показы, клики, подписчиков и расх }); test('прочерк — это «не знаем», а НЕ ноль', () => { - // 🔴 Граница честности спеки §3.1: написать клиенту «показов 0» там, где площадка - // просто не дала данных, — враньё. Ноль и «нет данных» обязаны различаться. + // 🔴 Граница честности: написать клиенту «показов 0» там, где площадка просто не + // дала данных, — враньё. Ноль и «нет данных» обязаны различаться. const s = parseStatsRow(CHERNOVIK); assert.equal(s.impressions, null); assert.equal(s.clicks, null); @@ -44,11 +43,10 @@ test('прочерк — это «не знаем», а НЕ ноль', () => { }); test('разделитель тысяч не ломает число — в том числе неразрывный пробел', () => { - // Кабинет разделяет тысячи неразрывным пробелом (код 160) — на нём уже обжигались. const ryad = [ 'Кампания', 'Завершить', 'Запущена', 'Каналы', 'Свой список', '400 ₽', '9 000 ₽', - '12 480', '1 205', '9,66 %', '7,1 ₽', '2 034', '16 %', '4,2 ₽', - '5 990,4 ₽', '0', '0 ₽', '0 ₽', '5 990,4 ₽', + '12 480', '1 205', '9,66 %', '7,1 ₽', '2 034', '16 %', '4,2 ₽', + '5 990,4 ₽', '0', '0 ₽', '0 ₽', '5 990,4 ₽', ]; const s = parseStatsRow(ryad); assert.equal(s.impressions, 12480); diff --git a/bots/mts-telegram-ads/test/status-zapushchena.test.mjs b/bots/mts-telegram-ads/test/status-zapushchena.test.mjs new file mode 100644 index 00000000..14cfc9ea --- /dev/null +++ b/bots/mts-telegram-ads/test/status-zapushchena.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseModerationStatus, STATUS_SLOVA_RE } from '../src/cabinet.js'; + +// 🔴 Живой случай 07.08.2026. МТС одобрил кампанию 2237821 — первую оплаченную роботом. +// Кабинет написал в списке седьмое слово: «Запущена». Робот его не знал и с 06:18 на +// каждом заходе отвечал «статус кампании не определён в списке», а портал держал +// кампанию на модерации с замороженными деньгами клиента. +// +// 🔑 Одобрение пришло, и мы о нём НЕ УЗНАЛИ БЫ вовсе: снаружи это выглядит точно так же, +// как «вердикта ещё нет». Отличить можно было только заглянув в кабинет глазами. +// +// Ряд списка, снятый живьём в тот день (уровень 8 от ссылки на кампанию): +const RYAD_ZHIVOY = '07.08 Telegram по своей базе Завершить Посмотреть настройки Запущена ' + + 'Каналы Свой список 400 ₽ 182,4 ₽ 2 0 0 % 0 ₽ 0 0 % 0 ₽ 0,96 ₽ 0 0 ₽ 0 ₽ 0,96 ₽'; + +test('«Запущена» — это одобрение: модерация пройдена, показы идут', () => { + assert.equal(parseModerationStatus('Запущена'), 'approved'); +}); + +test('живой ряд кабинета 07.08 читается как одобрение', () => { + assert.equal(parseModerationStatus(RYAD_ZHIVOY), 'approved'); +}); + +// 🪤 Вторая половина той же беды. Ряд кампании ищется подъёмом от ссылки вверх до +// предка, в тексте которого есть слово-статус. Добавь «Запущена» только в разбор — +// ряд не найдётся вовсе, разбору нечего будет читать, и починка будет выглядеть +// сделанной, оставаясь мёртвой. Ровно это уже случилось со словом «Запланирована». +test('слово «Запущена» опознаёт ряд в списке — иначе разбору нечего читать', () => { + assert.match('Запущена', STATUS_SLOVA_RE); + assert.match(RYAD_ZHIVOY, STATUS_SLOVA_RE); +}); + +test('прежние слова не сломались', () => { + assert.equal(parseModerationStatus('Отклонена'), 'rejected'); + assert.equal(parseModerationStatus('Одобрена'), 'approved'); + assert.equal(parseModerationStatus('Активна'), 'approved'); + assert.equal(parseModerationStatus('На модерации'), 'moderating'); + assert.equal(parseModerationStatus('Запланирована'), 'moderating'); + assert.equal(parseModerationStatus('Черновик'), 'draft'); + assert.equal(parseModerationStatus('Неведомое слово'), null); +}); diff --git a/bots/mts-telegram-ads/test/trevoga.test.mjs b/bots/mts-telegram-ads/test/trevoga.test.mjs new file mode 100644 index 00000000..d5272f9b --- /dev/null +++ b/bots/mts-telegram-ads/test/trevoga.test.mjs @@ -0,0 +1,112 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { otpravitTrevogu, otpravitOtchyot } from '../src/trevoga.js'; + +// 🔑 Замерено 06.08.2026. Сторож сессии честно ловил беду и пытался слать письмо, но +// отправитель `bot@liderra.ru` не существует — Яндекс отбивал КАЖДОЕ письмо ответом +// «553 Sender address rejected: user not found». А в keepalive.js стоял голый `catch {}`, +// который этот отказ съедал целиком. Итог: сторож в последний раз видел живой кабинет +// 04.08 в 20:01, потом 71 отказ подряд за двое суток — и НИ ОДНОГО письма владельцу. +// Молчаливый сбой сторожа: беда есть, крик есть, а слышать его некому. +// +// Правило: письмо не ушло — об этом обязаны узнать из журнала. Но сбой почты НЕ должен +// ронять самого сторожа: он и без письма пишет причину в журнал и отдаёт код выхода. + +function mailerChto(povedenie) { + const otpravleno = []; + return { + otpravleno, + async alarm(pismo) { + otpravleno.push(pismo); + if (povedenie) throw povedenie; + }, + }; +} + +test('письмо ушло — возвращаем «да» и в журнал ничего не пишем', async () => { + const zhurnal = []; + const mailer = mailerChto(null); + + const itog = await otpravitTrevogu( + { mailer, log: (s) => zhurnal.push(s) }, + { step: 'keepalive', reason: 'вход слетел', screenshotPath: null }, + ); + + assert.equal(itog, true); + assert.deepEqual(zhurnal, [], 'когда всё хорошо — молчим'); + assert.equal(mailer.otpravleno.length, 1); +}); + +test('почта отбила письмо — говорим «нет» и пишем причину В ЖУРНАЛ', async () => { + const zhurnal = []; + const mailer = mailerChto(new Error('553 5.7.1 Sender address rejected: user not found')); + + const itog = await otpravitTrevogu( + { mailer, log: (s) => zhurnal.push(s) }, + { step: 'keepalive', reason: 'вход слетел', screenshotPath: null }, + ); + + assert.equal(itog, false); + assert.equal(zhurnal.length, 1, 'сбой почты обязан оставить след'); + assert.match(zhurnal[0], /553 5\.7\.1 Sender address rejected/, 'в журнале — настоящий ответ почты'); +}); + +test('в журнале видно, что беда именно с ПИСЬМОМ, а не с кабинетом', async () => { + const zhurnal = []; + const mailer = mailerChto(new Error('соединение оборвалось')); + + await otpravitTrevogu({ mailer, log: (s) => zhurnal.push(s) }, { step: 'keepalive', reason: 'вход слетел' }); + + assert.match(zhurnal[0], /письмо/i, 'иначе читающий журнал решит, что это беда кабинета'); +}); + +test('сбой почты НЕ роняет сторожа — исключение наружу не летит', async () => { + const mailer = mailerChto(new Error('почта легла')); + await otpravitTrevogu({ mailer, log: () => {} }, { step: 'keepalive', reason: 'вход слетел' }); +}); + +// Та же немая ловушка стояла ещё в двух местах самого робота (`runner.js`): и письмо-отчёт +// об удачном запуске, и письмо-тревога о сбое кампании были обёрнуты в голый `catch {}`. +// Замысел верный — сбой почты не должен рушить уже состоявшийся запуск. Ошибка в том, +// что он делал это молча: писем не было НИ РАЗУ, и об этом никто не знал. +test('письмо-отчёт: почта отбила — работу не рушим, но след оставляем', async () => { + const zhurnal = []; + const mailer = { + async report() { throw new Error('553 Sender address rejected'); }, + }; + + const itog = await otpravitOtchyot( + { mailer, log: (s) => zhurnal.push(s) }, + { campaignId: '2237821', matched: 380, mode: 'live' }, + ); + + assert.equal(itog, false, 'соврать «ушло» нельзя'); + assert.equal(zhurnal.length, 1); + assert.match(zhurnal[0], /письмо/i); + assert.match(zhurnal[0], /553 Sender address rejected/); +}); + +test('письмо-отчёт ушло — молчим и говорим «да»', async () => { + const zhurnal = []; + const otpravleno = []; + const mailer = { async report(p) { otpravleno.push(p); } }; + + const itog = await otpravitOtchyot({ mailer, log: (s) => zhurnal.push(s) }, { campaignId: '1', matched: 2, mode: 'draft' }); + + assert.equal(itog, true); + assert.deepEqual(zhurnal, []); + assert.equal(otpravleno.length, 1); +}); + +test('без своего журнала пишем в stderr, а не молчим', async () => { + const mailer = mailerChto(new Error('почта легла')); + const prezhniy = console.error; + const zhurnal = []; + console.error = (...a) => zhurnal.push(a.join(' ')); + try { + await otpravitTrevogu({ mailer }, { step: 'keepalive', reason: 'вход слетел' }); + } finally { + console.error = prezhniy; + } + assert.equal(zhurnal.length, 1, 'умолчание не должно быть тишиной'); +}); diff --git a/bots/mts-telegram-ads/test/zaplanirovana.test.mjs b/bots/mts-telegram-ads/test/zaplanirovana.test.mjs new file mode 100644 index 00000000..2157fcab --- /dev/null +++ b/bots/mts-telegram-ads/test/zaplanirovana.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseModerationStatus, StatusNotRecognizedError, STATUS_SLOVA_RE } from '../src/cabinet.js'; + +// 🔑 Живой прогон 06.08.2026, кампания МТС 2237821 (первая ОПЛАЧЕННАЯ роботом): +// после оплаты кабинет ставит статус «Запланирована» — слова, которого читалка не +// знала. Итог: «статус не определён», отказ помечен НЕповторяемым, задание умерло +// навсегда. Вердикт не прочитался бы никогда, а 268,80 ₽ клиента остались бы +// замороженными до скончания века. +// +// «Запланирована» — НЕ вердикт: кампания оплачена и ждёт даты старта. Считаем её +// «ещё ждём» — деньги по факту списываем только на «Одобрена»/«Активна». + +test('«Запланирована» → moderating (ждём, деньги не трогаем)', () => { + assert.equal(parseModerationStatus('Запланирована'), 'moderating'); +}); + +test('«Запланирована» внутри строки-блоба ряда списка', () => { + assert.equal( + parseModerationStatus('07.08 Telegram по своей базе Отменить запуск Запланирована Каналы Свой список 400 ₽ 182,4 ₽'), + 'moderating', + ); +}); + +test('«Запланирована» не выдаёт себя за одобрение', () => { + assert.notEqual(parseModerationStatus('Запланирована'), 'approved'); +}); + +test('одобрение по-прежнему одобрение', () => { + assert.equal(parseModerationStatus('Одобрена'), 'approved'); + assert.equal(parseModerationStatus('Активна'), 'approved'); +}); + +// 🪤 Вторая половина той же грабли: ряд кампании в списке ищется ВНУТРИ страницы — +// поднимаемся от ссылки вверх до предка, в тексте которого есть слово-статус. Список +// этих слов свой, отдельный от разбора. Пока «Запланирована» в нём не было, ряд не +// находился вовсе — и разбор получал пустую строку, сколько его ни чини. +test('поиск ряда в списке знает те же слова, что и разбор', () => { + for (const slovo of ['Отклонена', 'Одобрена', 'Активна', 'На модерации', 'Запланирована', 'Черновик']) { + assert.match(slovo, STATUS_SLOVA_RE, `ряд со статусом «${slovo}» должен находиться`); + } +}); + +test('нераспознанный статус — отказ ВРЕМЕННЫЙ, задание вернётся в очередь', () => { + const e = new StatusNotRecognizedError('Статус кампании 123 не определён'); + assert.equal(e.retryable, true, 'новое слово в кабинете не повод хоронить задание'); +}); diff --git a/docs/superpowers/2026-08-07-VYKAT-telegram-pokazy-i-storozh.md b/docs/superpowers/2026-08-07-VYKAT-telegram-pokazy-i-storozh.md index fe79f0e7..89c697bf 100644 --- a/docs/superpowers/2026-08-07-VYKAT-telegram-pokazy-i-storozh.md +++ b/docs/superpowers/2026-08-07-VYKAT-telegram-pokazy-i-storozh.md @@ -1,7 +1,17 @@ # Выкат 07.08.2026 — показы телеграм-рекламы + починка сторожа робота -**Что это:** список к выкату, собранный ПЕРЕД выкатом. Сам выкат не начат. -**Разрешение владельца на выкат — ещё не дано.** Здесь только подготовка. +> ## ✅ ВЫКАЧЕНО 07.08.2026, 20:40–20:50 МСК +> +> Владелец скомандовал «делай бэкап и гони». Оба куска на боевом, цепочка проверена +> целиком: портал → задание → робот → база. Живой замер — кампания №15 (МТС `2237821`): +> **19 показов, расход площадки 9,12 ₽, снято 20:49**. +> +> Перед выкатом в ветку влита главная (`4a6860983`) — иначе сборка фронта откатила бы +> назад живой экран СМС. Подробности и бэкапы — в `ПИЛОТ.md`, запись 07.08 (вечер). +> +> Что НЕ выкачено: **П‑8 «Остановить»** (владелец запретил нажимать «Завершить»). + +**Что это:** список к выкату, собранный ПЕРЕД выкатом. Ниже — план, по которому шли. Два куска работы, независимых друг от друга. Можно катить по отдельности. diff --git a/ПИЛОТ.md b/ПИЛОТ.md index 07bd5a81..78b8b1a6 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -16,6 +16,71 @@ --- +## 🆕 ОБНОВЛЕНИЕ 07.08.2026 (вечер) — телеграм-реклама: клиент видит показы (на боевом) + +Выкачено **07.08.2026 около 20:40–20:50 МСК** по команде владельца «делай бэкап и гони». +Ветка `feat/prospects-manual-testing-kp` (`dc4d835b7` + слияние главной `4a6860983`). + +### Что изменилось для клиента + +Раньше после создания рекламы клиент был слеп: ни отчёта, ни причины отказа проверки, +ни способа что-то изменить, ни единой цифры о показах. Теперь на карточке рекламы — +«Увидят рекламу», «Зарезервировано», «Списано», **«Показов» / «Кликов» / «Подписались»**; +кнопки «Отчёт», «Отменить», «Повторить эту рекламу»; отчёт с **полным** текстом причин +отказа; пересдача принимает новый заголовок и картинку; вердикт уходит **письмом**. +Слова «МТС» на клиентских экранах телеграм-рекламы больше нет. + +### Что выкачено технически + +- **Миграция** `2026_08_07_000001_add_stats_to_client_tg_campaigns` — 5 колонок + (`stats_impressions`, `stats_clicks`, `stats_subscribers`, `stats_spend_rub`, + `stats_read_at`), журнал схемы **v9.73**. Применена ролью `crm_supplier_worker` + через `--database=pgsql_supplier`: она **входит в роль-владельца `crm_migrator`**, + и этого достаточно для `ALTER TABLE`. +- **24 файла сервера и фронта**, разложены поверх, сверены **байт в байт** с веткой. +- **Сборка фронта** выложена ДОБАВЛЕНИЕМ: 1108 кусков → 1191, ничего не удалено. + Живой сайт отдаёт `app-CV4wc0vP.js` — точку входа этой сборки. +- **Новый опросчик** `PollTelegramStatsJob`, раз в час. + +### Доказательство, что работает + +Полная цепочка проверена на боевом: портал поставил задание → робот сходил в кабинет → +цифры легли в базу. Кампания №15 (МТС `2237821`): **19 показов, 0 кликов, расход площадки +9,12 ₽, снято 20:49**. Арифметика сходится: 19 × 0,48 ₽ = 9,12 ₽. + +### 🔴 Что важно знать + +- **Расход площадки клиенту не показывается никогда.** `mts_cost_rub` и `stats_spend_rub` + скрыты в модели: из пары с «Списано» наценка Лидерры считается вычитанием. +- **Пустота остаётся пустотой.** Пока площадка не дала цифру, строки на экране нет — + не «0 показов». Проверено живьём: у отклонённой кампании все значения пустые. +- **Кампания №9 (МТС `2299002`) в кабинете НЕ НАХОДИТСЯ** — её нет в списке вовсе. + Робот честно отказывается и ничего не пишет, но раз в час даёт отказ в журнал + (`client_tg.stats_read_failed`). Похоже, номер устарел — требует решения владельца. +- **П‑8 «Остановить» не выкачен**: владелец запретил нажимать «Завершить» в кабинете, + без этого неизвестно, что кнопка делает с деньгами. + +### Бэкап отката (на боевом, `/home/ubuntu/`) + +`pre-deploy-2026-08-07-1845-code.tgz` (89 МБ), `-build.tgz` (6,2 МБ), +`-schema.sql` (926 КБ), `-tg-campaigns.csv` (все 12 строк меняемой таблицы). + +🔴 **Полный логический дамп базы снять НЕ УДАЛОСЬ**: `crm_supplier_worker` не имеет права +обходить защиту строк, `pg_dump` обрывается на первой RLS-таблице. Обрывок переименован в +`NEPOLNYY-ne-ispolzovat-1845-db.dump`. Полный дамп может снять только суперпользователь +(`postgres`/`admin`), его пароля в `.env` нет, `yc` на машинах не установлен. +Для этого выката это не было препятствием — миграция только ДОБАВЛЯЕТ пустые колонки. + +### Разбор чужой поломки того же дня + +В журнале боевого 07.08 с **13:00 до 14:17 МСК** сыпалось `relation "obzvon_calls" does +not exist` — падал экран сделок (сводка обзвона). Причина: код модуля обзвона выложили +**раньше его миграций**. К моменту разбора уже вылечено не нами: все четыре миграции +обзвона применены пачкой 59, таблица на месте, запрос работает, после 14:17 — ни одной +ошибки. **После выката телеграм-показов ошибок в журнале ноль.** + +--- + ## 🆕 ОБНОВЛЕНИЕ 07.08.2026 — вкладка «Активность» отвечает «жив ли клиент» (на боевом) Рабочая ветка `feat/prospects-manual-testing-kp` сведена в `main` (свод `b65ad2ecf`,