Три поломки одного вечера: рубль в заголовке, обёртка stdout и капча, которая выросла на весь экран
Вечером 02.09 у нас не вышел плановый пост в TenChat. В логе задачи планировщика — ничего осмысленного, просто ненулевой код возврата и алерт «слот не закрыт». Разбор растянулся на весь вечер, потому что там оказались не одна поломка, а три, и каждая маскировала следующую.
Контекст: у нас несколько скриптов на Python, которые публикуют контент на разные площадки (TenChat, Дзен) и синхронизируют себя из git-репозитория на Windows-сервер через .cmd-обёртки и schtasks. Все они запускаются без интерактивной консоли, из планировщика задач Windows.
Баг 1: рубль уронил гейт, а гейт — публикацию
Первая находка была самой обидной. Пост с заголовком, где встречался знак ₽, не проходил дальше проверки на дубли (dup_check.py). Сама проверка на дубли к рублю никакого отношения не имела — она честно сравнивала заголовки через difflib. Падало раньше, на первом же print.
Планировщик Windows запускает python.exe с консольной кодовой страницей по умолчанию — на нашем сервере это cp1251. У cp1251 нет символа ₽ (U+20BD), и обычный print в такую консоль падает. Проверил у себя:
>>> import io, sys
>>> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='cp1251', errors='strict')
>>> print('заголовок:', 'Скролл-фильм весил 84 МБ ₽')
UnicodeEncodeError: 'charmap' codec can't encode character '₽'
in position 25: character maps to <undefined>
Позиция 25 — это ровно индекс символа ₽ в строке. dup_check.py печатал заголовок для отладки первой же строкой после разбора аргументов, до какой-либо бизнес-логики. Исключение вылетало наружу, процесс завершался с ненулевым кодом, и вызывающий публикатор трактовал любой ненулевой код гейта как «нельзя публиковать». Технически гейт был прав только в одном — что-то пошло не так. Просто не там, где он думал.
Фикс тривиальный — не менять сам объект sys.stdout, а перенастроить его кодировку на месте:
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
errors='replace' вместо strict — чтобы отладочный print никогда больше не мог решать судьбу публикации. Это единственно правильное место для replace: сам заголовок в тело поста уходит без изменений, страдает только консольный вывод.
Баг 2: чем чинили баг 1, тем и сломали баг 2
До этой правки во всех пяти файлах публикатора уже стояла старая заплатка от прошлого раза, когда кто-то ловил похожую проблему с кодировкой:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
Она честно работает, если её применить один раз. Проблема в том, что tc_publish_outbox.py импортирует th_cdp.py, а тот — тоже модуль публикатора, и тоже переставляет sys.stdout этой же строкой при импорте. Получаются две обёртки одна поверх другой. Я проверил, что это ломает вывод немедленно, без ожидания сборщика мусора:
>>> import io, sys
>>> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
>>> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
>>> print('после двойной обёртки')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
(Python 3.11.9, но поведение не версийное — так работает TextIOWrapper в CPython вообще.)
Механика такая: первая обёртка владеет нижележащим буферизованным потоком. Как только на неё исчезает последняя ссылка (мы её просто перезаписали), CPython по счётчику ссылок уничтожает объект немедленно — обёртки не откладывают финализацию до цикла GC, как это бывает с объектами, участвующими в циклических ссылках. Деструктор TextIOWrapper закрывает и себя, и то, чем владеет, то есть общий sys.stdout.buffer. Вторая обёртка ссылается на тот же буфер — и он уже закрыт.
02.09 это ударило не по самой публикации (та успевала пройти до второго импорта), а по уборке после неё. tc_publish_outbox.py после успешной публикации переносит файл поста из outbox/ в outbox/published/ и печатает ГОТОВО: <url>. Импорт th_cdp происходит раньше, накладывает вторую обёртку, и когда доходит очередь до этого финального print, поток уже закрыт. Процесс падает с ValueError, перенос файла не происходит. Назавтра тот же файл лежал бы в outbox/ и ушёл бы в публикацию повторно — уже настоящим дублем, а не ложной тревогой.
Заодно нашлась вторая, независимая проблема в том же куске кода: перенос файла в published/ стоял ПОСЛЕ закрытия вкладки браузера. Если закрытие вкладки бросает исключение (а до правки из бага 2 оно бросало — на закрытой обёртке stdout), перенос так и не наступал, хотя публикация уже состоялась и была зарегистрирована в базе. Переставил перенос файла сразу после publib.register(...), до любых операций уборки:
publib.register('tenchat', url, src)
publib.remember_title(title, 'tenchat')
if not os.path.isdir(DONE):
os.makedirs(DONE)
shutil.move(src, os.path.join(DONE, os.path.basename(src)))
print('ГОТОВО:', url)
close_tenchat_tab()
Правило на будущее простое: между «опубликовано» и «отмечено опубликованным» не должно быть ничего, что может упасть.
Из пяти файлов заплатку sys.stdout = io.TextIOWrapper(...) убрали везде и заменили на reconfigure() — тот же метод, что чинил баг 1. reconfigure не создаёт новый объект и не закрывает ничего, поэтому накладывать его повторно из разных модулей безопасно любое количество раз.
Баг 3: капча Дзена перестала совпадать сама с собой
Третья поломка была у другого публикатора — Дзена, и не имела отношения к первым двум, просто всплыла в тот же вечер. Скрипт находит капчу по iframe с captcha|not_robot|smartcaptcha в src и кликает в точку с отступом 34px от левого края фрейма, ориентируясь на разметку, где чекбокс лежит слева.
02.09 разметка капчи была другой: iframe растягивался на весь экран (у нас это было ~1680×1020 при обычном окне браузера), а сама видимая панель с чекбоксом — небольшой квадрат по центру. Старое правило кликало в (34, 510) — в пустое место у левого края экрана, а не в панель. Хуже того, проверка «капча снята» смотрела на присутствие iframe с тем же CSS-классом, а после перерисовки виджет пересоздавался с новым id, и проверка каждый раз отвечала «снята», хотя капча оставалась на месте. Публикация тихо стояла, кнопка была неактивна, а автоматика была уверена, что всё хорошо.
По скриншоту в момент падения замерил: панель с чекбоксом — примерно 320×320px по центру экрана, сам чекбокс внутри неё смещён на 43px левее центра и на 48px ниже середины. Добавил отдельную ветку на случай полноэкранного фрейма:
const full = q.width > innerWidth * 0.7 && q.height > innerHeight * 0.7;
if (full) return JSON.stringify({
found: true, how: 'фрейм во весь экран',
x: Math.round(innerWidth / 2 - 43),
y: Math.round(innerHeight / 2 + 48),
});
Старая ветка «чекбокс у левого края фрейма» осталась ниже как запасной вариант для обычной, не растянутой капчи — убирать её не было причины, оба вида верстки Дзен показывает вперемешку.
Что не сработало
Первая гипотеза по багу 1 была «где-то битая кодировка файла с текстом поста» — проверил file и chardet на исходном .md, всё было честным UTF-8. Час ушёл впустую, потому что искал проблему в данных, а она была в канале вывода, который к данным отношения не имел.
По багу 2 первая версия правки была ещё хуже: обернуть print в try/except прямо в tc_publish_outbox.py и молча проглатывать ValueError. Это убрало бы видимый traceback, но не вернуло бы рабочий stdout — все последующие вызовы print в этом процессе (включая диагностические) так и остались бы немы, а перенос файла всё равно не выполнялся бы, если бы упал раньше по коду. Отказался от этого варианта, как только понял, что причина в архитектуре (пять файлов трогают один и тот же глобальный объект), а не в конкретной строке.
По багу 3 сначала попробовал расширить старое правило — увеличить область клика вокруг (34, y). Не помогло: дело было не в неточности клика, а в том, что при полноэкранном фрейме искомая точка находится совсем в другом месте экрана, никакое расширение области вокруг старой точки её не накрывает.
Итог по вечеру
Все три правки проверены не на «правдоподобно выглядит», а на воспроизведении: рубль в заголовке гейта — реальный UnicodeEncodeError с той же позицией символа; двойная обёртка stdout — реальный ValueError: I/O operation on closed file на минимальном повторении в отдельном интерпретаторе; капча — публикация статьи Дзена после клика по новым координатам. Ни один из трёх багов не всплыл бы на тестовом прогоне с обычной консолью в UTF-8, без рубля в заголовке и без перерисованной капчи — все три жили строго в проде, на конкретной кодовой странице планировщика и в конкретной раскладке виджета, которую сервис показывает не всегда.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.