Я дизайнер интерфейсов и работаю как фрилансер. На каждое коммерческое предложение у меня уходило часа три: смета в таблице, вёрстка, экспорт, проверка. Шаги одни и те же, меняются цифры и состав работ. В конце июля я упаковал его в Telegram-бота, который задаёт семь вопросов и присылает PDF со сметой, сроками, этапами и кейсами.

Расскажу, как смета собирается из свободного текста, почему PDF рендерит Chrome, как бот приводит обложки кейсов к одной пропорции и зачем ему мини-приложение. В конце грабли эксплуатации и цифры за два месяца.

Почему без зависимостей

Бот живёт на небольшом сервере, и ставить туда aiogram с цепочкой пакетов ради одного диалога не хотелось. Telegram Bot API это HTTPS и JSON. Для входящих есть getUpdates с long-polling: держим запрос открытым 25 секунд и получаем апдейты почти сразу, без вебхуков и сертификатов. Всё закрывается urllib из стандартной библиотеки.

Состояние диалога хранится в отдельном файле на каждого пользователя: строка state и накопленные ответы. Пришёл апдейт, прочитали файл, обработали, записали обратно. Базы нет. Профиль переживает перезапуски, прошлая ставка, контакты и кейсы подставляются в следующее предложение. Отлаживать удобно, открыл json и видишь весь путь человека. На сотнях одновременных пользователей понадобится нормальное хранилище. До этого пока далеко.

Так выглядит состояние на середине диалога:

{
  "chat_id": 123456789,
  "state": "rate",
  "profession": "design",
  "niche": "Сайт студии керамики",
  "services": [{"ptype": "design",
                "scope_text": "главная, каталог, 4 внутренние страницы, контакты простая"}],
  "saved_contacts": "@username",
  "saved_ptypes": ["design"],
  "kp_count": 2
}

Поле state это текущий шаг автомата. Всё, что начинается с saved_, относится к профилю. Эти поля переживают команду «новое предложение» и подставляются в следующий документ.

PIL единственная необязательная зависимость. Если её нет, обложки кейсов идут как есть.

Смета из свободного текста

Состав работ человек пишет как привык: «главная, каталог, 4 внутренние страницы, контакты простая». Парсер режет строку по запятым, точкам с запятой и переносам, из каждого фрагмента достаёт количество (цифрой или словом, с потолком 50 на случай опечатки «400 страниц») и по ключевым словам относит фрагмент к одной из трёх сложностей.

Разбор короткий, это почти весь код:

_COMPLEX_KW = ("главн", "слож", "лендинг", "дашборд", "каталог", "кабинет",
               "конструктор", "калькулятор", "корзин", "чекаут", "оформлени")
_SIMPLE_KW = ("прост", "текстов", "контакт", "404", "политик", "faq",
              "вопрос", "реквизит")

def parse_scope(text):
    rows = []
    for chunk in re.split(r"[,;\n+•·]", text):
        chunk = chunk.strip()
        if not chunk:
            continue
        count, rest = _extract_count(chunk)   # «4 страницы» → 4, «три» → 3
        tier = _classify(chunk.lower())       # по основам слов выше
        hours_each = TIERS[tier]["hours"]
        rows.append({"name": _clean_name(rest, tier), "count": count,
                     "tier": tier, "hours_each": hours_each,
                     "hours": count * hours_each})
    return rows

Ключевые слова это основы, а не словоформы: «главн» ловит и «главная», и «главную». Сложные проверяются раньше простых, поэтому «сложная форма контактов» уйдёт в сложные. Всё, что не попало ни в один список, считается стандартной страницей. Ошибку классификации человек видит сразу. Бот перечисляет, как понял состав, и строку можно поправить перед сборкой.

Бот разобрал состав по сложности и сразу спрашивает ставку.
Бот разобрал состав по сложности и сразу спрашивает ставку.

Дальше арифметика. Сложная страница 16 часов, стандартная 6, простая 4, плюс разовая позиция на анализ и UI-кит 8 часов. Часы умножаются на ставку человека. Срок считается от темпа, который он сам назвал: ceil(часы / часов_в_день) рабочих дней, в документе это выглядит как «17 рабочих дней (~4 недели)».

Подпись срока в документе считается так:

def workdays_label(hours, hours_per_day=4):
    days = max(1, math.ceil(float(hours) / hours_per_day))
    weeks = max(1, math.ceil(days / 5))
    return f"{days} {_days_word(days)} (~{weeks} {_weeks_word(weeks)})"

68 часов при темпе 4 часа в день дают 17 рабочих дней и около четырёх недель. Склонение слов «день» и «неделя» вынесено в отдельные функции, иначе в PDF выходит «17 рабочих день».

Шесть форматов меняют логику расчёта. «Сайт под ключ» добавляет строку вёрстки, 50% от часов дизайна. «Приложение под ключ» добавляет разработку, 100%. Логотип считается блоками: брифинг 4 часа, варианты знака по 4 часа каждый, доработка выбранного 6, логобук 6. Фирменный стиль добавляет к этому носители по 3 часа и гайдлайн 8 часов. Форматы можно отметить сразу несколько, сметы сложатся.

Сам бот ничего не оценивает. Ставку и состав задаёт человек, бот превращает их в документ. Поэтому перед сборкой есть ручная правка: любую строку можно переименовать, поменять часы или убрать одним тапом по «✕». Смета и этапы правятся независимо, ручные строки перекрывают авторасчёт.

Экран проверки перед сборкой и редактор сметы.
Экран проверки перед сборкой и редактор сметы.

PDF через headless Chrome

Библиотеку для PDF пришлось бы ставить отдельно, а вёрстку в ней описывать больно. Документ верстается обычным HTML и CSS: слайды A4 в альбомной ориентации, шрифты вшиты в HTML через base64, поэтому рендер одинаковый на любой машине. В PDF его превращает headless Chrome.

На нашем сервере сборка одного документа занимает около трёх секунд, файл весит от 60 до 250 КБ в зависимости от кейсов. Три стиля оформления это три набора CSS-переменных поверх одной вёрстки: фон, текст, линии, акцент. У цветного стиля акцент задаёт сам человек.

Обложки трёх стилей. Одна вёрстка, разные наборы переменных.
Обложки трёх стилей. Одна вёрстка, разные наборы переменных.
Слайд сметы в трёх стилях. Итог и расчётный срок стоят внизу, ставка в правом верхнем углу.
Слайд сметы в трёх стилях. Итог и расчётный срок стоят внизу, ставка в правом верхнем углу.

Рендер тяжёлый, Chrome может занять до минуты на слабой машине. Поэтому сборка вынесена в фоновую очередь с одним воркером. Один Chrome за раз не выедает память, а лёгкие шаги диалога других людей в это время обрабатываются без ожидания. Код очереди:

_PDF_Q = queue.Queue()

def enqueue_pdf(st):
    # снимок состояния: рендер не зависит от того, что человек нажмёт дальше
    _PDF_Q.put(copy.deepcopy(st))

def _pdf_worker():
    while True:
        st = _PDF_Q.get()
        try:
            generate_and_send(st)
        except Exception:
            log("ОШИБКА фоновой сборки PDF:\n" + traceback.format_exc())
            send(st["chat_id"], "Что-то сломалось при сборке PDF. Попробуй ещё раз: /start")
        finally:
            _PDF_Q.task_done()

Воркер запускается один раз в отдельном потоке при старте. Копия состояния нужна на случай, если человек после нажатия «собрать» сразу начнёт новое предложение. Документ всё равно соберётся по старым ответам.

Тот же HTML можно снять скриншотом с флагом --screenshot и проверить вёрстку глазами, не открывая PDF.

Обложки кейсов к одной пропорции

В предложение можно добавить до трёх кейсов. Картинки люди присылают любые: вертикальные скрины, широкие баннеры, квадраты. В сетке из трёх карточек разные пропорции ломают ряд. Поэтому бот приводит всё к пропорции 740×600, как у обложек проектов на dprofile, и даёт два способа на выбор.

def normalize_cover(src, dst, scale=2, mode="contain"):
    im = Image.open(src).convert("RGB")
    W, H = 740 * scale, 600 * scale
    canvas = Image.new("RGB", (W, H), _border_color(im))
    iw, ih = im.size
    r = (max if mode == "cover" else min)(W / iw, H / ih)
    nw, nh = round(iw * r), round(ih * r)
    canvas.paste(im.resize((nw, nh), Image.LANCZOS), ((W - nw) // 2, (H - nh) // 2))
    canvas.save(dst, "JPEG", quality=90)

cover заполняет рамку с обрезкой по центру, отрицательное смещение при вставке и есть кроп. contain вписывает картинку целиком на подложку, цвет которой бот берёт с рамки исходника: считает самый частый цвет по краям с округлением до шага 16.

Один и тот же баннер в двух режимах. Слева исходник, в центре «вписать без обрезки», справа «заполнить целиком».
Один и тот же баннер в двух режимах. Слева исходник, в центре «вписать без обрезки», справа «заполнить целиком».

На картинке выше видна цена округления. На светлом бежевом фоне подложка уходит в зелень, и шов заметен. На тёмных и белых фонах его нет. Это ещё предстоит поправить.

Если картинки под рукой нет, бот сам возьмёт обложку со страницы кейса.

Слайд кейсов. Обложки приведены к одной пропорции, под каждой название, результат и ссылка.
Слайд кейсов. Обложки приведены к одной пропорции, под каждой название, результат и ссылка.

Мини-приложение вместо семи сообщений

Второй способ заполнить те же данные это мини-приложение. Команда /app открывает анкету одним экраном. Это обычная HTML-страница, которую Telegram открывает внутри клиента. По кнопке она отдаёт боту JSON через sendData.

На стороне бота JSON раскладывается в те же поля состояния, что и диалог: форматы, состав через тот же парсер, ставка, темп, сроки, оплата, контакты, стиль. Дальше человек попадает на тот же экран проверки, что и в диалоге. Второй логики сборки нет. Кейсы мини-приложение пока не добавляет, это бета.

Анкета мини-приложения на десктопе и в телефоне.
Анкета мини-приложения на десктопе и в телефоне.

Пресет для разработчиков

Бот делался для дизайнеров. Чтобы проверить соседнюю аудиторию, я не стал заводить второго бота, а добавил пресет по диплинку t.me/kpnikitabot?start=razrab. Он ставит в профиль profession="dev", и диалог показывает другие форматы: сайт, веб-приложение, интеграция и API, телеграм-бот, доработка. Единицы тоже свои: страницы, экраны, методы, сценарии, задачи. Надбавок «под ключ» нет, разовая позиция называется «анализ ТЗ, оценка и архитектура решения».

Пресет для разработчика: свои форматы и разбор состава задачами.
Пресет для разработчика: свои форматы и разбор состава задачами.

Грабли эксплуатации

Второй экземпляр. Если запустить две копии бота, Telegram отвечает на getUpdates ошибкой 409 Conflict, а апдейты растаскиваются между процессами. Человек пишет боту, а тот будто не слышит. Лечится лок-файлом при старте:

lock = open(DATA / "bot.lock", "w")
try:
    fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
    log("Второй инстанс не запускаю: bot.lock занят")
    sys.exit(1)

Молчаливая смерть. Однажды процесс исчез без единой строки в логе, его убили SIGKILL, а этот сигнал не перехватить. Вежливые SIGTERM и SIGHUP бот теперь пишет в лог, а от невежливых стоит watchdog в cron: раз в пять минут pgrep -f ищет процесс по полному пути к файлу и поднимает его, если не нашёл. Плюс задача @reboot.

Поэтому бота запускаем только по полному пути. Один раз его запустили относительным путём, watchdog не узнал процесс в списке и поднял второй. Отсюда и были те самые 409.

Устойчивость цикла. Ошибка при обработке апдейта пишется в лог с полным traceback, цикл работает дальше. stdout и stderr продублированы в файл лога на уровне дескрипторов, поэтому туда попадает и то, что печатает не мой код, например падения Chrome.

Что не получилось и что поменяли

Люди не понимали аббревиатуру «КП». Я привык к ней за годы работы, а для многих она ничего не значит. Пришлось расшифровать во всех сообщениях, кнопках, на лендинге и в заголовке PDF. Слепой заменой это не делается, ломаются падежи.

В диалоге были тупики. Экраны-меню при вводе текста проваливались в ложное «готово», человек думал, что документ собран. Теперь меню на любой текст показывается заново. Кнопка /new посреди недособранного предложения сначала спрашивает подтверждение, чтобы одним нажатием не стереть десять минут ответов.

Цифры за период с 26 июля по 30 сентября, по профилям на сервере, без моих тестовых прогонов. Бота открыли 54 человека, хотя бы один документ собрали 18, всего 21 предложение. По датам файлов на сервере семь документов из десяти собраны в первую неделю после запуска, дальше про бота нигде не писали, и поток стих. Эта статья первая публичная.

Первую версию запустили 26 июля. Пресет для разработчиков появился на следующий день, мини-приложение в конце августа.

Лендинг бота: dgmx.ru/kp. Бот в Telegram: https://t.me/kpnikitabot.

Комментарии (1)


  1. xenon
    02.10.2026 21:09

    headless режим в хроме мне показался такой "нелюбимой падчерицей", которую в лес вывести еще не решаются, но и кормить уже не хотят. Знаю проект, который так же построен. И он примерно раз в год ломается, потому что при очередном апгрейде в хроме эта хедлесс рендерилка перестает работать. Пришлось даже хром в APT на холд ставить, чтобы вся система обновлялась, а он - нет.