Потребность поделиться диалогом из ChatGPT рано или поздно возникает. Однако нативного экспорта в удобный формат вроде PDF или Markdown платформа не предлагает. Копипаст — плохое решение: таблицы разваливаются, форматирование кода съезжает, а изображения просто пропадают. Мы решили эту задачу для себя, написав собственный конвертер. Оказалось, что он полезен не только нам.

Так появился pdfchatgpt.com. Принцип простой: копируешь share-ссылку и диалога с ChatGPT, добавляешь pdf в начало ссылки и получаешь готовый файл. Также можно перейти на pdfchatgpt.com и просто вставить ссылку на диалог.

В этой статье — технический разбор нашего решения: от простого скрипта с puppeteer до асинхронной системы с очередями. Делимся опытом для тех, кто решает схожие задачи.

Прототип и первые проблемы

Изначальная реализация была прямолинейной: скрипт, который принимает URL, запускает headless-браузер и «печатает» страницу в PDF. Но даже здесь нас ждали нюансы.

Проблема №1: Динамическая загрузка контента.

Страница с общим доступом к чату ChatGPT — это SPA. Контент подгружается по мере скролла. Простой захват страницы давал полупустой PDF. Решение — принудительная прокрутка страницы до самого низа, чтобы заставить ленивую загрузку отработать.

// ... псевдокод для ясности
async function resolveFileIdsToUrls(shareUrl, ids, options) {
  // ...
  // Прокрутка для ленивой загрузки
  await page.evaluate(async (scrollDelayMs) => {
    // ...
  }, options.scrollDelayMs);
  // ...
}

Это решило одну проблему, но вскрыло другую, более коварную.

Проблема №2: Обнаружение headless-браузеров.

В разметке страницы нет прямых ссылок на изображения. Вместо них — временные file- ID. Чтобы получить реальные URL-адреса CDN, нужно было запустить браузер и перехватить сетевые запросы к бэкенду OpenAI.

Наша первая попытка с playwright провалилась. ChatGPT, как и многие современные веб-приложения, использует системы защиты от ботов. Наш скрипт быстро детектировался, и вместо нужных данных мы получали от ворот поворот.

Решением стал patchright — форк playwright, специально "заточенный" под то, чтобы выглядеть как обычный пользовательский браузер. Он автоматически управляет такими вещами, как User-Agent и другими специфичными для браузера заголовками, что позволяет обходить базовую защиту.

// backend/src/parse/chatgpt/populate_dialog_with_images.ts
import { chromium, Browser, Page } from 'patchright'

export async function populateDialogWithImages (
  shareUrl: string,
  dialog: Dialog,
  options: PopulateOptions = {}
): Promise<Dialog> {
  // ...
  // 1. Собираем все ID изображений, которые нужно найти
  const pending = collectImageAttachments(newDialog)
  if (pending.ids.size === 0) {
    return newDialog // нечего разрешать
  }

  // 2. Запускаем УСИЛЕННЫЙ headless-браузер для получения прямых ссылок
  const idToUrl = await resolveFileIdsToUrls(shareUrl, pending.ids, {
    // ...
  })

  // 3. Применяем найденные URL к нашей структуре диалога
  applyResolvedUrls(pending.attachments, idToUrl)

  return newDialog
}

Это сработало. Мы не только научились обходить защиту, но и получили возможность извлекать изображения, которые были в диалоге, и вставлять их прямо в PDF, сохраняя контекст беседы.

Изображение из чата, корректно отображенное в нашем PDF
Изображение из чата, корректно отображенное в нашем PDF

Но запуск даже такого "умного" браузера на каждый запрос — это медленно и ресурсоемко. Для публичного сервиса требовалось более масштабируемое решение.

От скрипта к асинхронной архитектуре

Чтобы обрабатывать параллельные запросы без блокировок, мы перешли к асинхронной архитектуре с очередями.

Схема текущей системы:
  1. API-сервер на Fastify: Принимает запрос, валидирует его и создает задачу.

  2. Очередь задач (Redis + BullMQ): API немедленно возвращает клиенту jobId, а задача уходит в очередь.

  3. Воркер: Отдельный процесс, который забирает задачи из очереди и выполняет всю тяжелую работу: запуск браузера, парсинг, рендеринг.

  4. Хранилище S3: Результаты (PDF, Markdown) сохраняются в облако.

  5. Получение результата: Клиент опрашивает статус по jobId. Когда задача завершена, API отдает подписанные ссылки на скачивание файлов из S3.

Это позволило нам отделить легкий фронтенд-API от тяжелых фоновых процессов и масштабировать воркеры независимо.

Код постановки задачи в очередь:
// backend/src/server/routes/convert.post.ts
handler: async (request, reply) => {
  const { shareUrl, includeNavLists } = request.body
  const timezone = request.headers['time-zone'] ?? 'UTC'

  // Создаем задачу и не ждем ее выполнения
  const job = await addConversionJob({ shareUrl, includeNavLists, timezone })

  // Сразу отвечаем клиенту
  return reply.code(202).send({ jobId: job.id as string })
}
Код воркера, который выполняет конвертацию:
// backend/src/queue/worker.ts
const worker = new Worker('conversion', async (job) => {
  const { shareUrl, ... } = job.data
  const converter = new Converter()

  // Основная логика конвертации
  const { pdfBuffer, htmlBuffer, mdBuffer, ... } = await converter.convertToPdfAndHtml(
    shareUrl,
    // ...
  )

  // Загружаем результаты в S3
  await uploadPdfToS3(...)
  await uploadHtmlToS3(...)
  await uploadMarkdownToS3(...)

  return { s3Key, filename, ... }
});

Рендеринг: Дьявол в деталях

Получить сырой контент — это только полдела. Настоящая работа началась, когда мы задались целью сделать PDF не просто «читаемым», а неотличимым по качеству от оригинального интерфейса, а в чем-то даже лучше. Просто «напечатать» HTML через playwright было недостаточно. Вот несколько проблем, которые пришлось решить.

1. Подсветка синтаксиса

Код без подсветки синтаксиса — это просто стена текста. Мы не могли себе этого позволить. Пришлось интегрировать библиотеку для подсветки, чтобы каждый фрагмент кода в PDF выглядел так же четко и профессионально, как в IDE.

Пример pdf
Пример отображения в pdf
Пример отображения в pdf

2. Корректное отображение эмодзи

Эмодзи — частая проблема при генерации PDF. В зависимости от системы они могут отображаться как пустые квадраты (□) или как черно-белые символы. Чтобы обеспечить кросс-платформенную консистентность, мы встроили в PDF специальный набор шрифтов, который гарантирует корректное и цветное отображение всех эмодзи.

Пример pdf

3. Адаптивные таблицы

Что делать с таблицами, у которых 10+ колонок? В интерфейсе ChatGPT они получают горизонтальный скролл. В PDF такой трюк не пройдет — контент просто обрежется. Мы реализовали механизм, который анализирует таблицы и аккуратно переформатирует их, чтобы они помещались на страницу без потери данных и читаемости.

Пример из chatgpt ДО
В самом chatpdf таблица не влезает, но они решают проблему горизонтальным скроллом
В самом chatpdf таблица не влезает, но они решают проблему горизонтальным скроллом
Пример pdf
Здесь мы сделали верстку получше, чем в chatgpt
Здесь мы сделали верстку получше, чем в chatgpt

4. Бесшовный просмотр PDF

Мелочь, которая имеет значение. При просмотре PDF на macOS в режиме непрерывной прокрутки стандартные разрывы страниц создают заметные визуальные швы. Мы настроили параметры генерации PDF таким образом, чтобы эти переходы были абсолютно незаметны, создавая ощущение единого, бесконечного документа.

Пример pdf
Демонстрация бесшовной прокрутки страниц PDF в режиме Preview на macOS
Демонстрация бесшовной прокрутки страниц PDF в режиме Preview на macOS

Только после решения этих деталей мы сочли, что рендеринг работает как надо.

Выводы

Так пет-проект превратился в стабильно работающий сервис.

  1. Парсинг динамических сайтов всегда сложнее, чем кажется. Headless-браузеры — мощный, но требовательный инструмент.

  2. Асинхронная архитектура с очередями — стандарт де-факто для задач с непредсказуемым временем выполнения. Библиотеки вроде BullMQ сильно упрощают реализацию.

  3. Итеративная разработка. Начинать с простого прототипа и усложнять его по мере необходимости — рабочая стратегия.

Будем рады, если вы опробуете сервис в деле: pdfchatgpt.com

Конструктивная критика и предложения приветствуются. Дайте знать в комментариях, удалось ли решить вашу задачу и что можно было бы улучшить. Готов также ответить на технические вопросы.

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