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: Markitdown Universal MCP Server

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

Один процесс 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/rehypeallowDangerousHtml: 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.

A
license - permissive license
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Turn markdown into designed PDFs with cover page, table of contents, and code blocks that hold across pages. One command from Claude Desktop, Claude Code, Cursor, Cline, Zed, or any MCP-capable client.
    2
    38
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Provides a standardized interface for interacting with Markitdown's tools and services through a unified API, compatible with MCP-compliant services.
    MIT

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rozetyp/paperpress'

If you have feedback or need assistance with the MCP directory API, please join our Discord server