робот телеграма: рабочая копия принята в репозиторий + запись о выкате
Хвост, который висел с начала дня: рабочая копия робота на машине 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:
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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('служба остановлена');
|
||||
@@ -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 от
|
||||
|
||||
@@ -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
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 — запуск кампании. Незнакомый режим сюда же: портал шлёт только
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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} попыток`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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:40–20:50 МСК
|
||||
>
|
||||
> Владелец скомандовал «делай бэкап и гони». Оба куска на боевом, цепочка проверена
|
||||
> целиком: портал → задание → робот → база. Живой замер — кампания №15 (МТС `2237821`):
|
||||
> **19 показов, расход площадки 9,12 ₽, снято 20:49**.
|
||||
>
|
||||
> Перед выкатом в ветку влита главная (`4a6860983`) — иначе сборка фронта откатила бы
|
||||
> назад живой экран СМС. Подробности и бэкапы — в `ПИЛОТ.md`, запись 07.08 (вечер).
|
||||
>
|
||||
> Что НЕ выкачено: **П‑8 «Остановить»** (владелец запретил нажимать «Завершить»).
|
||||
|
||||
**Что это:** список к выкату, собранный ПЕРЕД выкатом. Ниже — план, по которому шли.
|
||||
|
||||
Два куска работы, независимых друг от друга. Можно катить по отдельности.
|
||||
|
||||
|
||||
@@ -16,6 +16,71 @@
|
||||
|
||||
---
|
||||
|
||||
## 🆕 ОБНОВЛЕНИЕ 07.08.2026 (вечер) — телеграм-реклама: клиент видит показы (на боевом)
|
||||
|
||||
Выкачено **07.08.2026 около 20:40–20:50 МСК** по команде владельца «делай бэкап и гони».
|
||||
Ветка `feat/prospects-manual-testing-kp` (`dc4d835b7` + слияние главной `4a6860983`).
|
||||
|
||||
### Что изменилось для клиента
|
||||
|
||||
Раньше после создания рекламы клиент был слеп: ни отчёта, ни причины отказа проверки,
|
||||
ни способа что-то изменить, ни единой цифры о показах. Теперь на карточке рекламы —
|
||||
«Увидят рекламу», «Зарезервировано», «Списано», **«Показов» / «Кликов» / «Подписались»**;
|
||||
кнопки «Отчёт», «Отменить», «Повторить эту рекламу»; отчёт с **полным** текстом причин
|
||||
отказа; пересдача принимает новый заголовок и картинку; вердикт уходит **письмом**.
|
||||
Слова «МТС» на клиентских экранах телеграм-рекламы больше нет.
|
||||
|
||||
### Что выкачено технически
|
||||
|
||||
- **Миграция** `2026_08_07_000001_add_stats_to_client_tg_campaigns` — 5 колонок
|
||||
(`stats_impressions`, `stats_clicks`, `stats_subscribers`, `stats_spend_rub`,
|
||||
`stats_read_at`), журнал схемы **v9.73**. Применена ролью `crm_supplier_worker`
|
||||
через `--database=pgsql_supplier`: она **входит в роль-владельца `crm_migrator`**,
|
||||
и этого достаточно для `ALTER TABLE`.
|
||||
- **24 файла сервера и фронта**, разложены поверх, сверены **байт в байт** с веткой.
|
||||
- **Сборка фронта** выложена ДОБАВЛЕНИЕМ: 1108 кусков → 1191, ничего не удалено.
|
||||
Живой сайт отдаёт `app-CV4wc0vP.js` — точку входа этой сборки.
|
||||
- **Новый опросчик** `PollTelegramStatsJob`, раз в час.
|
||||
|
||||
### Доказательство, что работает
|
||||
|
||||
Полная цепочка проверена на боевом: портал поставил задание → робот сходил в кабинет →
|
||||
цифры легли в базу. Кампания №15 (МТС `2237821`): **19 показов, 0 кликов, расход площадки
|
||||
9,12 ₽, снято 20:49**. Арифметика сходится: 19 × 0,48 ₽ = 9,12 ₽.
|
||||
|
||||
### 🔴 Что важно знать
|
||||
|
||||
- **Расход площадки клиенту не показывается никогда.** `mts_cost_rub` и `stats_spend_rub`
|
||||
скрыты в модели: из пары с «Списано» наценка Лидерры считается вычитанием.
|
||||
- **Пустота остаётся пустотой.** Пока площадка не дала цифру, строки на экране нет —
|
||||
не «0 показов». Проверено живьём: у отклонённой кампании все значения пустые.
|
||||
- **Кампания №9 (МТС `2299002`) в кабинете НЕ НАХОДИТСЯ** — её нет в списке вовсе.
|
||||
Робот честно отказывается и ничего не пишет, но раз в час даёт отказ в журнал
|
||||
(`client_tg.stats_read_failed`). Похоже, номер устарел — требует решения владельца.
|
||||
- **П‑8 «Остановить» не выкачен**: владелец запретил нажимать «Завершить» в кабинете,
|
||||
без этого неизвестно, что кнопка делает с деньгами.
|
||||
|
||||
### Бэкап отката (на боевом, `/home/ubuntu/`)
|
||||
|
||||
`pre-deploy-2026-08-07-1845-code.tgz` (89 МБ), `-build.tgz` (6,2 МБ),
|
||||
`-schema.sql` (926 КБ), `-tg-campaigns.csv` (все 12 строк меняемой таблицы).
|
||||
|
||||
🔴 **Полный логический дамп базы снять НЕ УДАЛОСЬ**: `crm_supplier_worker` не имеет права
|
||||
обходить защиту строк, `pg_dump` обрывается на первой RLS-таблице. Обрывок переименован в
|
||||
`NEPOLNYY-ne-ispolzovat-1845-db.dump`. Полный дамп может снять только суперпользователь
|
||||
(`postgres`/`admin`), его пароля в `.env` нет, `yc` на машинах не установлен.
|
||||
Для этого выката это не было препятствием — миграция только ДОБАВЛЯЕТ пустые колонки.
|
||||
|
||||
### Разбор чужой поломки того же дня
|
||||
|
||||
В журнале боевого 07.08 с **13:00 до 14:17 МСК** сыпалось `relation "obzvon_calls" does
|
||||
not exist` — падал экран сделок (сводка обзвона). Причина: код модуля обзвона выложили
|
||||
**раньше его миграций**. К моменту разбора уже вылечено не нами: все четыре миграции
|
||||
обзвона применены пачкой 59, таблица на месте, запрос работает, после 14:17 — ни одной
|
||||
ошибки. **После выката телеграм-показов ошибок в журнале ноль.**
|
||||
|
||||
---
|
||||
|
||||
## 🆕 ОБНОВЛЕНИЕ 07.08.2026 — вкладка «Активность» отвечает «жив ли клиент» (на боевом)
|
||||
|
||||
Рабочая ветка `feat/prospects-manual-testing-kp` сведена в `main` (свод `b65ad2ecf`,
|
||||
|
||||
Reference in New Issue
Block a user