Skip to main content
Glama

paperpress

API, которая определяет бренд компании по её живому URL — логотип, основной цвет, шрифт — и превращает markdown в PDF, оформленный в этом бренде, одним HTTP-запросом. Поставляется как обычный REST API и как MCP-сервер.

POST /v1/documents
{ "markdown": "# Q4 report\n...", "brandFromUrl": "stripe.com" }
→ ~1s → signed URL to a PDF in Stripe's brand

О проекте

Этот проект был создан и недолго работал как реальный развёрнутый сервис, прежде чем я внимательно изучил рынок, на котором ему пришлось бы конкурировать: Claude теперь нативно генерирует PDF/PPTX/DOCX, а Brandfetch уже продаёт Brand Context API, созданный специально для grounding AI-агентов, с реальными платящими клиентами. Обе половины того, что делает этот сервис — определять бренд и рендерить документ — сейчас либо близки к коммодитизации, либо уже принадлежат финансируемому конкуренту. Я не развиваю это как продукт.

Проект публичен как портфолио / эталонная реализация: рабочий детектор бренда на Playwright (CSS custom properties, сэмплирование цвета CTA-кнопок, извлечение кандидатов в логотипы с оценкой, WCAG-проверка контраста), синхронный конвейер рендеринга на Fastify, SSRF-защищённый загрузчик URL и MCP-сервер, оборачивающий всё это. Читайте код, форкайте, запускайте — лицензия MIT. Как продукт не поддерживается: платёжная обработка не подключена, а MCP-пакет (mcp/) не опубликован в npm.

Related MCP server: docjet-mcp

Как это работает

Один процесс Fastify делает три вещи:

  1. Определение (src/render/detect.ts) — переходит по целевому URL с помощью пула Playwright/Chromium, читает theme-color, брендовые CSS custom properties, цвета CTA-кнопок, кандидатов в логотипы из <img> в шапке, оцениваемых по позиции/размеру/формату, и вычисленные font stacks. Отфильтровывает шум почти белого/почти чёрного/почти серого, применяет WCAG-проверку яркости, чтобы слишком светлый фирменный цвет не испортил контраст текста.

  2. Рендеринг (src/render/) — превращает markdown в HTML через unified/remark/rehype (с allowDangerousHtml: false), применяет одну из пяти тем плюс определённый/переданный бренд-кит и печатает в PDF через Playwright.

  3. Обслуживание — PDF сохраняются на локальный диск (или в смонтированный том) и раздаются по HMAC-подписанным URL с ограниченным сроком действия.

Никакой очереди, воркер-процессов или Redis. Рендеринг синхронный и обычно занимает 100–400 мс, когда Chromium уже прогрет; определение кэшируется на 24 часа на хост.

Что здесь есть

.
├── src/                       Fastify API (single process)
│   ├── index.ts               Bootstrap, route registration
│   ├── env.ts                 Env validation (zod)
│   ├── lib/                   prisma, auth, billing, storage, email, url-fetch (SSRF guard), inline-image
│   ├── render/                markdown → HTML → PDF (themes/, detect.ts)
│   └── routes/                auth, documents, demo, account, pdf, brand-kits, admin
├── prisma/schema.prisma       5 models: User, ApiKey, Document, CreditTransaction, BrandKit
├── mcp/                       MCP server (unpublished — see mcp/README.md)
├── samples/                   Example output (see Examples below) + input markdown used to generate it
├── scripts/                   preview.ts / detect.ts — regenerate the samples/ output locally
├── Dockerfile                 Single-image deploy (Playwright base)
└── railway.json               Railway config (healthcheck only — start cmd is in Dockerfile)

API (v1)

Аутентификация

Метод

Путь

Auth

Что делает

POST

/auth/register

-

Запросить ключ по email. Всегда 202; ключ отправляется на почту. Первый раз = новый пользователь + бесплатные кредиты. Последующие вызовы = ротация (старые ключи действуют 24 часа, затем отзываются).

Рендеринг

Метод

Путь

Auth

Что делает

POST

/v1/documents

Bearer key

Markdown → PDF. Принимает theme, brandKit (сохранённое имя или инлайн), brandFromUrl (ярлык «определить и применить»), css, format, landscape, title. Возвращает подписанный URL. 413, если отрендеренных страниц > MAX_PAGES_PER_RENDER (по умолчанию 200).

GET

/pdf/:id?exp=&sig=

signed URL

Потоковая передача PDF

Бренд-киты

Метод

Путь

Auth

Что делает

POST

/v1/brand-kits

Bearer key

Создать/обновить сохранённый кит по имени (один на пользователя на имя)

GET

/v1/brand-kits

Bearer key

Список ваших китов

GET

/v1/brand-kits/:id

Bearer key

Прочитать один кит

DELETE

/v1/brand-kits/:id

Bearer key

Удалить кит

POST

/v1/brand-kits/detect

Bearer key

Передайте URL, получите primaryColor, logoUrl, favicon, fontFamily, fontStyle. Кэш 24 часа. Передайте { refresh: true }, чтобы обойти.

POST

/v1/brand-kits/detect-batch

Bearer key

До 20 URL параллельно через существующий пул Playwright. Ошибки по каждому элементу возвращаются инлайн.

Аккаунт / здоровье

Метод

Путь

Auth

Что делает

GET

/account

Bearer key

Email, кредиты, список активных API-ключей (в открытом виде, чтобы пользователи могли их восстановить)

GET

/health

-

{ status: 'ok' }

Демо (анонимно, с ограничениями)

Без аутентификации, без списания кредитов. Строгий лимит на IP (30/час) поверх глобальных 60/мин.

Метод

Путь

Что делает

POST

/v1/demo

{ url } → определить бренд (кэш) + отрендерить встроенный samples/demo-q4-review.md как PDF. Возвращает кит + подписанный URL.

Админка (только чтение)

Ограничена заголовком X-Admin-Token. Когда ADMIN_TOKEN не задан, все маршруты /admin/* возвращают 404 — никакой поверхности, никакого обнаружения.

Метод

Путь

Что делает

GET

/admin/stats

Итоги: пользователи, активные ключи, документы, страницы, байты, кредиты, количество документов за последние 24ч / 7д

GET

/admin/users

Постраничный список с количеством документов и ключей на пользователя

GET

/admin/documents

Постраничный список с присоединённым email пользователя. Фильтр по userId, status.

GET

/admin/documents/:id/pdf

Потоковая передача любого PDF без подписанного URL

Меры безопасности

  • API-ключи: 192-битные случайные, хранятся в открытом виде (чтобы /account мог их показывать); отзыв использует метку revokedAt с льготным окном.

  • Подписанные URL: HMAC-SHA256, параметры exp + sig, TTL по умолчанию 7 дней.

  • SSRF-защита: assertPublicUrl резолвит DNS и отклоняет RFC1918, loopback, link-local, IPv6 ULA на URL, который передаёт вызывающий. Применяется к brandKit.logoUrl и /v1/brand-kits/detect. Тот факт, что переданный хост публичный, не гарантирует, что каждый переход публичный — публичный хост может редиректить на приватный адрес — поэтому и путь навигации Playwright (src/render/detect.ts), и загрузка инлайн-изображений (src/lib/inline-image.ts) повторно проверяют адрес на каждом шаге редиректа перед переходом и ещё раз после финальной навигации. Это сужает окно, но не устраняет его полностью: начальное соединение с целью редиректа происходит до того, как повторная проверка может его отклонить, поэтому решительный злоумышленник всё ещё может вызвать слепой исходящий запрос (данные ответа ему не возвращаются), хотя содержимое страницы с отклонённой цели никогда не извлекается и не рендерится. Полное закрытие потребовало бы IP-pinning на сетевом уровне.

  • Защита от CSS-инъекций: поле css отклоняет <style>, </style>, <script>, </script> — иначе сырая вставка внутри <style>${css}</style> позволила бы злоумышленнику вырваться наружу и выполнить JS в пуле Chromium.

  • Санитизация markdown: remark-rehype работает с allowDangerousHtml: false, поэтому <script> в теле markdown вырезается.

  • Лимиты: markdown ≤ 500 КБ, css ≤ 50 КБ, тело ≤ 2 МБ, рендеринг ≤ 30 с, отрендеренных страниц ≤ MAX_PAGES_PER_RENDER (по умолчанию 200), частота ≤ 60 запросов/мин/ключ.

  • Админка: сравнение токена за константное время; маршруты возвращают 404 (не 401), когда токен неверный или не задан.

Известные ограничения

Это не продукт, поэтому они раскрыты, а не занесены в бэклог:

  • Нет автоматизированного набора тестов. Всё вышеперечисленное проверялось вручную на работающем инстансе; регрессионной защиты нет.

  • Нет закоммиченных миграций Prisma. Команда запуска контейнера выполняет prisma db push --skip-generate --accept-data-loss.

  • Лимитирование в памяти и кэш определения. Нормально для одного инстанса; не переживёт несколько реплик без переноса обоих в общее хранилище.

  • Платёжная обработка не подключена. Система кредитов существует в схеме и API; ничего не списывает с карты.

  • MCP-пакет (mcp/) не опубликован в npm и не будет — см. mcp/README.md.

Локальная разработка

# 1. Postgres running locally on 5432
# 2. Env
cp .env.example .env
# (set SIGNING_SECRET to `openssl rand -base64 32`)

# 3. Install + migrate
npm install
npx prisma migrate dev

# 4. Run
npm run dev

Смоук-тест:

# Request a key. Response is { "sent": true } - the key arrives by email.
# In dev (RESEND_API_KEY unset) the server logs the email to stdout; grab the
# key from there.
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

export PP_KEY="pp_live_..."

# Render
curl -X POST http://localhost:3000/v1/documents \
  -H "Authorization: Bearer $PP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"markdown":"# Hello\n\nWorld.","title":"Test"}'

MCP

См. mcp/README.md. Тонкий MCP-клиент поверх REST API. Не опубликован в npm — включён как справочный код, а не как устанавливаемый инструмент.

Деплой (пример на Railway)

Точные шаги, использованные для реального деплоя, под которым это работало — оставлены здесь как документация, а не как приглашение запускать это в проде.

# 1. Create project with a Postgres database
railway init --name paperpress
railway add --database postgres

# 2. Create the app service. DATABASE_URL is wired via service reference.
railway add --service paperpress \
  --variables "DATABASE_URL=\${{Postgres.DATABASE_URL}}" \
  --variables "SIGNING_SECRET=$(openssl rand -base64 32)" \
  --variables "PUBLIC_BASE_URL=https://your-app.up.railway.app" \
  --variables "STORAGE_DIR=/data/storage" \
  --variables "NODE_ENV=production" \
  --variables "FREE_TIER_CREDITS=100" \
  --variables "PLAYWRIGHT_MAX_CONTEXTS=2" \
  --variables "RENDER_TIMEOUT_MS=30000" \
  --variables "KEY_GRACE_PERIOD_HOURS=24" \
  --variables "MAX_PAGES_PER_RENDER=200" \
  --variables "ADMIN_TOKEN=$(openssl rand -base64 36 | tr -d '\n')"

# 3. Attach a volume so PDFs survive container restarts
railway service paperpress
railway volume add --mount-path /data/storage

# 4. Domain (auto-detects the container port)
railway domain --port 3000

# 5. Ship
railway up --detach -c

Примечания:

  • В Dockerfile в качестве базового образа используется mcr.microsoft.com/playwright:vX.Y-jammy. Держите эту версию в синхроне с npm-пакетом playwright — несоответствие означает, что бинарник браузера не будет существовать и рендеринг упадёт.

  • startCommand в railway.json намеренно отсутствует: Railway разбирает его как argv (не как shell), поэтому цепочки команд через && не работают. CMD в Dockerfile оборачивает в sh -c и выполняет полную последовательность запуска.

  • Email: пока не задан RESEND_API_KEY, ключи регистрации пишутся в stdout. Ищите [email:console].

Примеры

Все они лежат в samples/ — сгенерированы скриптами scripts/preview.ts и scripts/detect.ts, перегенерируйте их сами через npx tsx scripts/preview.ts / npx tsx scripts/detect.ts <url>.

Один и тот же markdown, пять тем (ниже показана clean — полный PDF):

пример темы clean

Бренд автоматически определён с живого URL (brandFromUrl: "stripe.com" — полный PDF):

пример с определением бренда Stripe

Исходный URL

Определено + отрендерено

github.com

detect-github-com.pdf

railway.com

detect-railway-com.pdf

vercel.com

detect-vercel-com.pdf

Встроенные бренд-киты (без URL, поля передаются напрямую в запросе) — forest, mono-coral, stripe-colors.

Входной markdown, использованный выше: sample.md, demo-q4-review.md (тот, что используется /v1/demo).

Статус

Реализовано: markdown → PDF в 5 темах, автоопределение бренд-кита по URL (настоящий стек font-family, а не просто категория serif|sans|mono), кэш определения на 24 часа, шорткат в один вызов brandFromUrl, пакетное определение (20 URL параллельно), защита по яркости WCAG, выпуск ключей на основе email с льготным периодом ротации, MCP-сервер, административный интерфейс только для чтения, подписанные share-URL, предварительно отрендеренные документы.

Намеренно не реализовано: обработка платежей, npm-публикация MCP-пакета, автоматические тесты, настоящие миграции Prisma. Это не живой бэклог — он завершён как портфолио-работа и не разрабатывается в сторону 1.0.

Лицензия

MIT — см. LICENSE.

Related MCP Connectors

Related MCP Servers