У TenChat нет публичного API для публикации постов. Отложенная публикация в самом сервисе есть только на платном тарифе (на 28.09.2026 от 990 ₽ в месяц), а мне нужен был простой сценарий: раз в день берём следующую статью из блога, собираем из неё короткий пост с обложкой и выкладываем без участия человека. Я написал для этого скрипт на Python и Playwright, он работает на сервере по cron с конца сентября 2026 года. В статье разберу, как скрипт входит в аккаунт без пароля, как заставить редактор Quill принять готовую вёрстку, чем завершились первые недели и какие грабли попались при переезде с ноутбука на сервер.
Оговорюсь сразу: площадке автоматизация через браузер может не нравиться, так что правила сервиса стоит прочитать до запуска. Я публикую только свои тексты, по одному посту в день и в своём аккаунте, защиту скрипт не обходит и человеком не притворяется. Дальше будет только инженерная часть.
Что получилось
Схема получилась такая:
Блог лежит в репозитории markdown-файлами, у каждой статьи в шапке есть поле
date.Для каждой статьи заранее готовится короткая версия для TenChat: заголовок, несколько абзацев, хештеги последней строкой.
Раз в день на сервере запускается
poster.py. Он выбирает первую подходящую статью из очереди, поднимает браузер без окна, открывает редактор, заполняет заголовок, обложку и текст, нажимает «Опубликовать».Адрес вышедшего поста дописывается в текстовый журнал, при сбое в Телеграм приходит сообщение.
Стек: Python 3.11, Playwright для Python (на сервере стоит 1.63.0), Pillow для конвертации обложки, всё в одном виртуальном окружении. Всё помещается в один файл примерно на 200 строк, базы и очередей нет. Журнал опубликованного я веду в обычном текстовом файле: в строке дата, slug статьи и адрес поста.
Очередь по датам
Статей в блоге больше, чем влезет в ленту, да и не все годятся для TenChat: там сидят предприниматели, и посты про вайб-кодинг и разработку им ни к чему. Поэтому статья попадает в очередь, если её slug не ловится стоп-списком, её ещё нет в журнале и её дата уже наступила.
SKIP = re.compile(r"claude|vajb|kurs|obuchenie|vzroslomu|zarabotat|prompty|kejs-") def post_date(slug): m = re.search(r"^date: (\S+)", (POSTS / f"{slug}.md").read_text(encoding="utf-8"), re.M) return m.group(1) if m else "9999" def queue(): posted = {l.split()[1] for l in LOG.read_text(encoding="utf-8").splitlines() if l.strip()} today = datetime.date.today().isoformat() slugs = [p.stem for p in POSTS.glob("*.md") if not SKIP.search(p.stem) and p.stem not in posted] return sorted((s for s in slugs if post_date(s) <= today), key=post_date)
Дата хранится строкой 2026-09-30, а такие строки сравниваются ровно как даты, так что парсить их в datetime я не стал. Статья без поля date получает 9999 и в очередь не попадёт никогда. Это на случай, если я забыл заполнить шапку: пропущенный день я переживу спокойнее, чем выложенный черновик.
Стоп-список у меня обычная регулярка по slug, то есть по имени файла. Способ грубый, но его видно сразу и правится он одной строкой. Для фильтра по теме текста пришлось бы ещё раз звать нейросеть, и городить это ради десятка слов в именах файлов я не захотел.
Дальше next_ready() берёт первую статью очереди, у которой есть готовый файл с коротким текстом. Если файла нет, скрипт пробует сгенерировать его соседним скриптом, но не больше трёх раз за запуск, чтобы не зациклиться на проблемной статье. На сервере этого соседнего скрипта нет (тексты я готовлю на ноутбуке, и они приезжают вместе с остальным), поэтому в логе иногда мелькает can't open file, после чего скрипт спокойно берёт следующую статью. Пугаться тут нечего, хотя по-хорошему генератор на сервере звать вообще не надо, и этот пункт висит у меня в списке мелких долгов.
Вход без пароля: storage_state
Логин и пароль в скрипте не хранятся. Я вошёл в аккаунт один раз руками в отдельном профиле браузера, выгрузил состояние сессии (cookies и localStorage) в JSON-файл и дальше подсовываю его Playwright:
br = p.chromium.launch() ctx = br.new_context(storage_state=str(STATE), locale="ru-RU", viewport={"width": 1400, "height": 900}) page = ctx.new_page()
По сути этот файл и есть ключ от аккаунта. Он лежит рядом со скриптом с правами 600, глобальный .gitignore не пускает его в git, в логи он тоже не попадает. Будете повторять схему, обращайтесь с ним как с паролем.
Сессия живёт не вечно, поэтому скрипт пересохраняет состояние после каждого запуска, в блоке finally:
finally: ctx.storage_state(path=str(STATE)) br.close()
Сервис по ходу работы обновляет токены, браузер получает свежие cookies, и в файл ложится уже продлённая сессия, так что при ежедневных запусках вход продлевается сам. В finally я вынес сохранение потому, что запуск может упасть уже после того, как сервис обновил куки, например на редакторе, и было бы обидно потерять из-за этого свежее состояние.
Редактор: Quill и вставка HTML
Редактор TenChat сделан на Quill (в DOM видны .ql-editor и кнопка .ql-file). Заголовков он не умеет, жирный текст, списки и цитаты умеет. Печатать тело поста через keyboard.type мне не хотелось: текст на несколько тысяч знаков набирался бы долго, да ещё пришлось бы на ходу расставлять форматирование сочетаниями клавиш.
Поэтому я собираю HTML и вставляю его так, будто пользователь нажал Ctrl+V. На событие paste Quill читает clipboardData, разбирает text/html своим конвертером и переводит в свою модель документа, так что хватает синтетического события:
page.evaluate("""h => { const ed = document.querySelector('.ql-editor'); ed.focus(); const dt = new DataTransfer(); dt.setData('text/html', h); dt.setData('text/plain', ''); ed.dispatchEvent(new ClipboardEvent('paste', {clipboardData: dt, bubbles: true, cancelable: true})); }""", body_html)
Chromium принимает clipboardData прямо в конструкторе ClipboardEvent, и DataTransfer с двумя типами данных выглядит для редактора как настоящая вставка. text/plain я оставил пустым намеренно: если положить туда сырой текст, редактор иногда выбирает его, и форматирование теряется.
Сначала markdown нужно перегнать в такой HTML, который Quill переварит. Подмножество там маленькое, и я пишу его руками, без библиотеки:
def md_to_html(md): def inline(s): s = html.escape(s) s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s) return re.sub(r"\[(.+?)\]\((.+?)\)", r"\1 (\2)", s) ... if re.match(r"#+\s", line): out.append(f"<p><strong>{inline(line.lstrip('# '))}</strong></p>") elif line.startswith(">"): out.append(f"<blockquote>{inline(line.lstrip('> '))}</blockquote>") else: out.append(f"<p>{inline(line)}</p>") out.append("<p><br></p>")
Заголовок становится жирным абзацем, раз настоящих заголовков в редакторе нет. После каждого абзаца я добавляю пустой <p><br></p>, иначе Quill ставит абзацы почти вплотную и в ленте получается стена текста. Markdown-ссылку я превращаю в «текст (адрес)»: тег <a> редактор при вставке может переделать по-своему или выкинуть, а адрес в скобках останется на виду в любом случае.
Со ссылками я потом наткнулся ещё на одну вещь. Гостю без входа в аккаунт опубликованный пост показывает адрес как <span> с классом вида tc-link-login-tooltip, то есть обычным текстом с подсказкой «войдите, чтобы перейти». Кликнуть по нему посетитель с улицы не может, адрес придётся копировать руками. Что видят залогиненные, я не проверял, но если вам важны переходы из поста, имейте в виду, что часть читателей получит ссылку простым текстом.
Заголовок и обложка
Заголовок скрипт вводит в первый contenteditable на странице. Тут вместо type я взял insert_text: он кладёт строку одним событием ввода и не эмулирует отдельные нажатия, так быстрее.
page.locator("[contenteditable]").first.click() page.keyboard.insert_text(title)
С обложкой пришлось повозиться. Скрытый <input type=file> у таких редакторов обычно появляется в DOM при клике и сразу пропадает, и ловить его селектором ненадёжно. Зато Playwright умеет перехватывать системный диалог выбора файла:
with page.expect_file_chooser() as fc: page.locator(".ql-file").click() fc.value.set_files(str(cover)) page.wait_for_timeout(5000)
Пять секунд ожидания после загрузки обложки я честно считаю костылём. Нормального признака «картинка загрузилась» в интерфейсе я не нашёл и дал загрузке запас с избытком, чтобы текст не начал вставляться раньше времени. Цифру подбирал на глаз по скриншотам dry-run.
Ещё редактор принимает только JPEG и PNG, а картинки для сайта у меня хранятся в webp. Поэтому скрипт сначала ищет .jpg, а если его нет, на лету конвертирует webp через Pillow во временный файл:
if not cover.exists(): from PIL import Image Image.open(cover.with_suffix(".webp")).convert("RGB").save(TMP, "JPEG", quality=88) cover = TMP
Конвертацию в RGB пришлось добавить после ошибки: webp с прозрачностью без неё в JPEG не пишется.
Проверка перед нажатием и dry-run
Перед нажатием скрипт проверяет, что кнопка вообще активна:
btn = page.get_by_role("button", name="Опубликовать") if not btn.is_enabled(): raise RuntimeError("кнопка «Опубликовать» неактивна") if dry: page.screenshot(path=str(ROOT / "deploy" / "dry.png"), full_page=True) return "dry-run: всё заполнено, не публикую" btn.click() page.wait_for_url(lambda u: "/editor" not in u, timeout=60000) return page.url
Если кнопка неактивна, почти всегда что-то не вставилось: не загрузилась обложка или пустой текст. В таком случае я предпочитаю, чтобы скрипт упал и написал мне, а не отправил пост без картинки.
Режим --dry-run проходит все шаги, кроме последнего клика, и сохраняет полноразмерный скриншот редактора. Я гоняю его после каждой правки вёрстки и после обновления Playwright: открываю картинку и смотрю, как редактор принял текст. Код работает с чужим интерфейсом без всякого контракта, и такая пробная публикация мне помогает больше юнит-тестов. В боевом режиме перед запуском ещё стоит случайная пауза до часа (--no-delay её отключает), чтобы посты не выходили каждый день в одну и ту же минуту.
Если в журнале уже есть запись с сегодняшней датой, скрипт пишет «сегодня пост уже был» и выходит. Это на случай второго запуска за день, например ручного после правки, чтобы он не выложил лишний пост.
Уведомления в Телеграм
О любом сбое скрипт пишет мне в Телеграм, иначе тихую поломку я заметил бы через неделю по пустой ленте. Отправка живёт у меня в соседнем модуле отчётов и сводится к одному запросу к Bot API:
def notify(text): try: requests.post( f"https://api.telegram.org/bot{os.environ['BOT_TOKEN']}/sendMessage", data={"chat_id": os.environ["CHAT_ID"], "text": text}, timeout=15) except Exception as e: print(f"не смог написать в Телеграм: {e}")
Токен и номер чата скрипт берёт из переменных окружения (BOT_TOKEN и CHAT_ID тут условные имена), в коде их нет. В try/except отправка завёрнута, чтобы упавшее уведомление не заслоняло основную ошибку и не меняло код выхода.
Сообщения бывают такие: «пост не вышел» с подсказкой, куда смотреть (вход истёк или поменялся редактор), «готовых постов нет» и «вышел такой-то, в очереди осталось N». Последнее приходит, только когда в запасе меньше трёх постов: мне хватает знать, что пора готовить следующую пачку, а про каждый удачный пост писать незачем.
Переезд с ноутбука на сервер
Сначала скрипт жил на моём Mac и запускался через launchd (системный планировщик macOS) в 11 утра. Ноутбук для этого должен быть включён и не спать, и в первый же выходной это сломалось. Вдобавок launchd запускает процесс с урезанным окружением, и мой скрипт, который готовит тексты, не находил CLI из домашней папки: нужного каталога не было в PATH. Тексты молча не писались, а я заметил это только по пустой очереди. Чинится одной строкой с PATH в самом plist, и с тех пор я помню, что у планировщика окружение своё и с терминальным не совпадает.
Поэтому публикацию я перенёс на сервер, где уже крутился cron для блога:
0 8 * * * cd /srv/poster && .venv/bin/python poster.py >> poster.log 2>&1
Chromium там запускается без окна (p.chromium.launch() по умолчанию headless), доставить пришлось только зависимости Playwright (playwright install --with-deps chromium). Тексты постов по-прежнему готовит ноутбук и подвозит на сервер вместе с остальными файлами, потому что для генерации нужен CLI, а на сервере его нет. Старый plist лежит в репозитории на случай отката, вернуть всё назад можно за пару минут.
Запуск в 08:00 по UTC плюс случайная пауза до часа дают выход поста между 11 и 12 по Москве.
Грабли: общий вход, кто первый обновил, тот и выбил другого
Больше всего времени при переезде я потерял вообще не на коде. С первым выгруженным файлом состояния сервер не вошёл, потому что TenChat успел выдать новую сессию. Я выгрузил состояние из окна браузера на ноутбуке и ещё минут сорок возился с настройкой сервера, а окно на ноутбуке тем временем само обновило сессию. Старый токен умер, сервер остался разлогиненным, и сработала только повторная, свежая выгрузка.
Думаю, это касается любого сервиса с ротацией сессий: один вход нельзя держать в двух местах сразу, кто первым обновит сессию, тот и выбьет второго. Так что после переезда окно с этим аккаунтом на ноутбуке я не открываю. Сессией владеет только сервер, он пересохраняет её после каждого запуска. Если вход всё-таки слетит, я один раз вхожу руками, выгружаю состояние, кладу его на сервер и дальше этот профиль на ноутбуке не трогаю.
По той же причине в этом профиле нельзя «чуть-чуть проверить руками». Открыть ленту и глянуть на пост очень хочется, но так я сам выбью сервер из аккаунта, поэтому смотрю посты в отдельном браузере без входа.
Поломка: однажды редактор не открылся
За первую неделю на сервере вышло шесть постов за семь дней (с 29 сентября по 5 октября). 4 октября скрипт упал с таймаутом на ожидании .ql-editor: страница редактора не отрисовалась за 30 секунд. Мне пришло сообщение о сбое, я заглянул в лог, увидел Timeout на селекторе и чинить ничего не стал.
Причину я так и не выяснил. Вход не слетал: следующим утром тот же файл состояния сработал без обновления, и та же статья вышла нормально. Подозреваю нагрузку на стороне сервиса или задержку сети, но доказательств у меня нет. Зато скрипт повёл себя ровно так, как я задумывал: упал с сообщением, в журнал ничего не записал и в следующий запуск взял ту же статью. Запись в журнал появляется только после успешной публикации, так что повторный запуск пост не пропускает и дубля не делает.
Ретраев внутри запуска и «умных» обходов в скрипте нет. При одной неудаче в неделю мне проще повторить попытку руками, а пару пропущенных суток лента переживёт.
Что бы я сделал иначе
Не вызывал бы генератор текстов на сервере, где его нет. Сейчас скрипт пробует, падает с
can't open fileи идёт дальше, и всё работает, только лог из-за этого похож на список ошибок.Заменил бы пятисекундную паузу после загрузки обложки на ожидание конкретного элемента, как только найду в DOM устойчивый признак готовности. Пока признака нет, пауза остаётся.
Написал бы отдельный короткий запуск, проверяющий, жив ли вход. Сейчас о слетевшей сессии я узнаю по упавшей публикации, хотя хватило бы раз в день открыть редактор без отправки.
Положил бы версии зависимостей в lock-файл. Playwright обновляется часто, и после апдейта браузера редактор может выглядеть для скрипта уже иначе, а из защиты у меня здесь только dry-run со скриншотом.
Хранил бы журнал не текстом, а в SQLite. Для одного поста в день текстового файла хватает, но разбирать ошибки в нём неудобно.
Итог
Когда у сервиса нет API, браузерная автоматизация закрывает задачу за вечер. Сохранённую сессию я берегу как пароль и не делю с другим браузером. Текст отдаю через ClipboardEvent, минуя клавиатуру, и форматирование Quill расставляет сам. Вокруг этого стоят проверки: активна ли кнопка, dry-run со скриншотом, запись в журнал только после успеха и сообщение в Телеграм при любом сбое. Сам код вставки и публикации занимает строк сорок, а всё остальное нужно, чтобы скрипт падал понятно и ничего не ломал. Если будете писать похожее, начните со скриншота dry-run и добавьте публикацию последней.