Упавший скрипт меня не пугает. Он падает, крон пишет в лог, в телеграм прилетает ошибка, я иду чинить. Неприятно, но честно.
Пугает другое. Скрипт, который каждое утро бодро отчитывается «статей к проверке: 30», месяцами работает и месяцами обрабатывает не те данные.
У меня на маленьком VPS живёт хозяйство небольшого медиапроекта: в кроне 31 задача. Дозоры за комментариями и статьями на чужих площадках, за спросом в Вордстате, за новыми законами, бэкапы, напоминалки. Пишет и правит всё это в основном ИИ‑агент, я ставлю задачи и проверяю. И за полгода я собрал коллекцию поломок, у которых одна общая черта: ни одна ничего не уронила. Лог выглядел здоровым, отчёты выходили по расписанию, а внутри был мусор или пустота.
Ниже пять приёмов, которыми я эти поломки ловлю. Каждый выучен на конкретном случае.
Приём 1. Подставной параметр
Был дозор за комментариями в нескольких разделах большого медиасайта. Брал списки статей из RSS вида /rss/flows/<раздел>/, проходил по статьям, искал свежие комментарии. Лог: «статей к проверке 30». Каждый день 30, всё сходится.
Однажды я посмотрел глазами, какие именно статьи он проверяет в разделе коротких заметок. Там были кроссворды и материал про Выборг. Ни того, ни другого в разделе не было.
Дело было так: сервер игнорировал название раздела и на любой адрес отдавал общую ленту журнала. Ответ был побайтово одинаковый, 38 156 байт, для security, short, avto и для потока, которого не существует. Двести, OK, вот вам лента.
И тот же баг независимо жил во втором дозоре. Потому что код был скопирован из первого.
С тех пор перед тем, как строить что‑то на источнике, я делаю одну проверку. Запросить его с заведомо несуществующим ключом и сравнить с настоящим:
a = fetch(«https://example.ru/feed/nastoyashchiy‑razdel/»)
b = fetch(«https://example.ru/feed/takogo‑razdela‑net‑xyz/»)
assert a!= b, «параметр игнорируется, источник отдаёт одно и то же»
Три строки. Если бы они стояли с первого дня, месяцы работы не ушли бы в кроссворды.
(Для полноты картины: сейчас эти адреса на сайте отвечают 404. Источник умер, но хотя бы честно.)
Тот же приём работает не только с HTTP. Недавно заводил дозор за новыми законами: модель читает ленту документов и отбирает те, что касаются тем наших статей. Первая версия промпта звучала как «отбери только касающиеся», и модель стабильно отвечала пустым списком. Ноль новых законов, красота.
Я подложил ей в ленту пару документов, которые касаются нас заведомо. Ответ тот же: []. Фильтр был слепой. Заработал он только тогда, когда модель стала выносить решение по каждому документу отдельно, полем kasaetsya: true/false, а не выбирать из кучи.
Вывод одной строкой: ноль надо проверять подставным ненулём. Иначе ты не знаешь, пусто там или ты не видишь.
Приём 2. Два источника вместо одного
Один источник сверить не с чем. После истории с RSS я стал брать списки статей раздела двумя путями: HTML‑лента с пагинацией и карта сайта раздела sitemap‑flow‑<раздел>.xml.
Естественно ожидать, что официальная карта сайта полнее. Ничего подобного. Лента при глубокой пагинации дала 594 статьи, карта 387. «Пропавшие» живы и отвечают 200, их просто нет в карте. В обратную сторону расхождение было в одну статью.
Так что полнота у ленты, а карта годится для дат обновления и как страховка. Это теперь записано прямо в коде, и модуль, который собирает списки, при расхождении пишет предупреждение в лог сам. Без сверки я бы выбрал карту, потому что она «официальная», и потерял треть статей.
Сюда же отнесу случай с кодировкой, хотя он про другое. Дозор по тегам одной развлекательной площадки фильтровал посты по ключевым словам и честно приносил ноль. Фильтр был в порядке. Страницы тегов там отдаются в windows-1251, а скрипт слепо делал decode(«utf-8»). Русские слова превращались в кашу, ключевые слова в каше не находились. Ошибка сидела на два шага раньше того места, где её искали.
Приём 3. «Не знаю» живёт отдельно
Был пайплайн, который проверял, проиндексированы ли наши страницы в поиске. Смысл его был в одном: выдать приоритетный список «в индексе точно нет, надо что‑то делать».
За семь отчётов этот список не наполнился ни разу.
Причин две, и обе банальные. Проверка «в ответе поиска есть хоть один документ» засчитывала нестрогие совпадения, то есть записывала в индекс всё подряд. А когда запрос к поиску падал, функция возвращала None, и дальше по коду он попадал в ту же ветку, что и «да»:
bucket = backup if idx else prio
Питон не видит разницы между None и False в условии. «Не проверили» молча превращалось в утверждение.
Лечится явным третьим состоянием:
bucket = “prio” if idx is False else “backup” if idx is True else “unknown”
Похожая история с Планировщиком Windows. Ночная задача бэкапа неделями выглядела нормально: в журнале LastTaskResult = 0. Только машина ночью спит, а у задачи не стоял флаг «будить компьютер». Задача не стартовала вовсе, а ноль в поле результата остался от прошлого удачного прогона. Ноль там означает «в последний раз, когда задача работала, всё было хорошо», про сегодня он молчит. Смотреть надо LastRunTime и NextRunTime вместе: если следующий запуск перепрыгнул через сегодня, день потерян. Так недельный бэкап у меня незаметно стал двухнедельным.
Приём 4. Никаких молчаливых ошибок
Самое честное, что может сделать сломанный скрипт, это упасть. Самое вредное: поймать исключение и вернуть пустой список.
try:
data = fetch(url)
except Exception:
return [] # дозор скажет «ничего нового», и это будет ложь
У меня такое запрещено без исключений: любая пойманная ошибка оставляет строку в логе с причиной. Если её нечем обработать, пусть падает.
Но молчать умеют не только except. Бэкапы с домашней машины на VPS почти две недели не делались, и я узнал об этом случайно. Сбоев было два, и оба без единой строки в логе.
Первый: под Планировщиком у процесса нет консоли, ssh наследовал висячий stdin и ждал ввода на первой же команде. Вечно. Задача висела в статусе «выполняется», бэкапа не было. Иногда поверх работы всплывало пустое чёрное окно, и я его закрывал не связывая одно с другим. Лечится stdin=subprocess.DEVNULL, ключом ‑n у ssh и флагом CREATE_NO_WINDOW.
Второй: скрипт запускался через pythonw, а у него sys.stdout бывает None. Первая же строка sys.stdout.reconfigure(...) роняла скрипт раньше, чем он успевал что‑либо записать в лог. В логе тишина, в Планировщике код 1, который никто не смотрит. Теперь весь main() обёрнут так, что traceback попадает в лог при любом исходе.
Отдельно про сам лог. Строка «статей к проверке: 30» одинакова, когда всё хорошо и когда пришёл мусор, поэтому она бесполезна. Полезны числа, которые можно сверить: сколько из источника А нашлось в источнике Б, сколько отбросил фильтр, сколько новых против прошлого раза. Если половина выдачи не проходит тематический фильтр, источник скорее всего не тот.
Приём 5. Кто сторожит сторожей
Раз дозоры умеют тихо умирать, нужен дозор над дозорами. У меня это скрипт, который раз в день читает crontab, для каждой записи считает, сколько раз она должна была сработать с последнего настоящего прогона, и сравнивает со следами на диске. В телеграм пишет только когда что‑то не так, иначе на его сообщения быстро перстанут смотреть.
Вердиктов шесть: «в норме», «отстаёт», «пустой», «молчит», «не наступал» и «не знаю». Самый ценный из них «пустой»: дозор бегает исправно, отчёты выходят, но состоят из сообщений об ошибке источника. Так вскрылось, что Google Trends месяц подряд отдавал одному из дозоров только HTTP 429.
Но и надсмотрщик врал. Однажды он сообщил, что дозор ленты Дзена отстал на 292 прогона. Дозор при этом работал каждые полчаса.
Вот здесь и порылась собака. Следом жизни я взял файл, который дозор переписывает, когда отправляет тревогу в телеграм. А тревога у него случается редко: молчание и есть норма. Здоровый дозор числился мёртвым. Правильный след это файл, который меняется на каждом прогоне: состояние, накопительный лог, свежий отчёт.
Из той же серии: часть задач на сервере не напоминания мне, а команды агенту. Крон отправляет в телеграм «пора гнать конвейер статей», агент подхватывает из лога бота и работает. Так было задумано. Два дня всё «работало», пока я не полез в лог и не нашёл там ни одной такой строки. Скрипт слал сообщение от имени бота, а бот через getUpdates своих же сообщений не получает. Команды уходили мне в телефон как напоминания и никогда не доходили до исполнителя. Теперь скрипт после удачной отправки сам дописывает команду в лог.
Бонус: свои следы в чужих данных
Последний случай не про скрипты, но про ту же болезнь.
Я считал переходы на сайт из Дзена через свой редиректор с метками. Насчитал 90 переходов с тринадцати материалов и гордо вписал в презентацию. Потом сел разбираться, кто эти люди.
Это был я. Вернее, агент, который после настройки редиректора проверял, что ссылки работают. Клики шли пачками по 3–11 в одну минуту, с одного user agent, и обрывались ровно тогда, когда закончилась проверка. Вторым источником стала Метрика: за 16 дней Дзен привёл 2 визита.
Теперь при любом замере я сначала отсеиваю пачки (несколько кликов одной метки в одну минуту почти всегда проверка), потом сверяю со вторым источником. Презентацию пришлось переделать.
Что в итоге
Все случаи выше объединяет одно: ни одна поломка не выглядела как поломка. Где скрипт падал, он — падла — падал без следа в логе, а остальные исправно работали и приносили не то. Мониторинг падений тут бы не помог.
Поэтому чек‑лист у меня короткий:
источник проверен на подставной параметр, фильтр проверен подставными данными;
есть второй источник, и в коде записано, кто из них за что отвечает;
«не знаю» живёт отдельно от «нет»;
ни одного except: pass, в логе только проверяемые числа;
над дозорами есть надсмотрщик, и его следы обновляются каждый прогон, а не только при тревоге;
«починилось» говорю после нескольких прогонов по расписанию, а не после одного.
Последний пункт, кстати, самый трудный. Очень хочется после первого зелёного прогона написать «всё работает» и пойти пить что привык. Иногда так и есть.
А у вас какой самый долгий мониторинг, который честно работал и врал?
Last_Mile
"Последний случай не про скрипты, но про ту же болезнь." . - Это только начало объяснений всего, вплоть до атомов и законов Мёрфи в конкретном месте в конкретное время...