робот телеграма: рабочая копия принята в репозиторий + запись о выкате

Хвост, который висел с начала дня: рабочая копия робота на машине
C:\liderra\mts-telegram-robot была НОВЕЕ репозитория. Одиннадцать файлов жили
только там — вся служба bin/sluzhba.js со сторожами, отправщик тревог trevoga.js
и девять тестов. Любой, кто выложил бы репозиторий поверх, снёс бы это молча.

Теперь репозиторий = рабочая копия, файл в файл, сверено побайтово по 50 файлам.
Ничего не потеряно ни с одной стороны: рабочая копия уже содержала и мои
сегодняшние правки, так что перенос вышел без слияния вручную.

Что приехало от соседней смены
- служба bin/sluzhba.js + src/sluzhba-plan.js и три её сторожа
- src/trevoga.js — отправщик тревог, починенный 06.08
- чтение даты старта, пропуск подсказок модерации, слово «Запланирована»
- сторожа: оплата-гейт, шов даты старта, статус «Запущена»

Что уехало туда от меня, ещё утром
- чтение показов: parseStatsRow, setStatsPeriodAllTime, readCampaignStats
- режим read-stats в runner.js, poll-plan.js, bin/poll.js
- сторож сессии ходит С ОКНОМ вместо безоконного браузера

Тесты робота: 239 зелёных.

Документы
- ПИЛОТ.md — запись о выкате телеграм-показов на боевой 07.08 вечером: что
  изменилось для клиента, чем доказано, где бэкапы, чего снять не удалось и
  почему, плюс разбор чужой поломки obzvon_calls того же дня
- список к выкату помечен как исполненный

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-08 05:42:53 +03:00
parent f28222bc44
commit 42e06a4e17
36 changed files with 2906 additions and 224 deletions
@@ -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);
+21 -2
View File
@@ -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
}
+25 -6
View File
@@ -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;
@@ -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
@@ -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);
@@ -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
+180
View File
@@ -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('служба остановлена');
@@ -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('служба остановлена');
+2 -5
View File
@@ -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 от
+212 -96
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -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 {};
}
+1
View File
@@ -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 — запуск кампании. Незнакомый режим сюда же: портал шлёт только
+18 -14
View File
@@ -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 };
+33 -13
View File
@@ -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} попыток`,
);
+79
View File
@@ -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;
}
+3 -7
View File
@@ -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) {
+31
View File
@@ -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;
}
}
@@ -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');
});
@@ -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);
});
@@ -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);
});
@@ -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;
},
);
});
@@ -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);
});
@@ -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' });
@@ -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');
});
@@ -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);
});
@@ -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 () => {
@@ -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');
});
@@ -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);
});
@@ -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');
});
+8 -10
View File
@@ -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);
@@ -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);
});
+112
View File
@@ -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, 'умолчание не должно быть тишиной');
});
@@ -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, 'новое слово в кабинете не повод хоронить задание');
});
@@ -1,7 +1,17 @@
# Выкат 07.08.2026 — показы телеграм-рекламы + починка сторожа робота
**Что это:** список к выкату, собранный ПЕРЕД выкатом. Сам выкат не начат.
**Разрешение владельца на выкат — ещё не дано.** Здесь только подготовка.
> ## ✅ ВЫКАЧЕНО 07.08.2026, 20:4020:50 МСК
>
> Владелец скомандовал «делай бэкап и гони». Оба куска на боевом, цепочка проверена
> целиком: портал → задание → робот → база. Живой замер — кампания №15 (МТС `2237821`):
> **19 показов, расход площадки 9,12 ₽, снято 20:49**.
>
> Перед выкатом в ветку влита главная (`4a6860983`) — иначе сборка фронта откатила бы
> назад живой экран СМС. Подробности и бэкапы — в `ПИЛОТ.md`, запись 07.08 (вечер).
>
> Что НЕ выкачено: **П‑8 «Остановить»** (владелец запретил нажимать «Завершить»).
**Что это:** список к выкату, собранный ПЕРЕД выкатом. Ниже — план, по которому шли.
Два куска работы, независимых друг от друга. Можно катить по отдельности.
+65
View File
@@ -16,6 +16,71 @@
---
## 🆕 ОБНОВЛЕНИЕ 07.08.2026 (вечер) — телеграм-реклама: клиент видит показы (на боевом)
Выкачено **07.08.2026 около 20:4020: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`,