Skip to main content
Glama

paperpress

ライブURLから企業のブランド(ロゴ、プライマリカラー、フォント)を検出し、そのブランドでスタイルされたPDFにマークダウンをレンダリングするAPIです。1回の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はすでにAIエージェント向けに特化したBrand Context APIを実顧客から料金を取って販売しています。このプロダクトが行うこと(ブランド検出とドキュメントレンダリング)は、どちらもコモディティ化が進むか、すでに資金力のある競合他社が占有しています。製品として追求するつもりはありません。

ポートフォリオ/リファレンス実装として公開しています。Playwrightベースのブランド検出器(CSSカスタムプロパティ、CTAボタンの色サンプリング、スコアリングされたロゴ候補の抽出、WCAGコントラストガード)、同期Fastifyレンダリングパイプライン、SSRF対策済みURLフェッチャー、そしてそれをラップするMCPサーバーで構成されています。コードを読んだり、フォークしたり、実行したり自由です。MITライセンスです。製品としてメンテナンスされているわけではありません。決済処理は組み込まれておらず、MCPパッケージ(mcp/)はnpmに公開されていません。

Related MCP server: docjet-mcp

仕組み

単一のFastifyプロセスが3つのことを行います。

  1. 検出(src/render/detect.ts)— プールされたPlaywright/Chromiumインスタンスで対象URLにアクセスし、theme-color、ブランドCSSカスタムプロパティ、CTAボタンの色、位置・サイズ・形式でスコアリングされたヘッダー<img>候補、計算済みフォントスタックを読み取ります。白に近い・黒に近い・グレーに近いノイズを除去し、WCAGの輝度ガードを適用して、明るすぎるブランドカラーがテキストのコントラストを壊さないようにします。

  2. レンダリング(src/render/)— マークダウンをunified/remark/rehype(allowDangerousHtml: false)でHTMLに変換し、5つのテーマと検出・指定されたブランドキットを適用して、PlaywrightでPDFに出力します。

  3. 配信 — PDFはHMAC署名付き・有効期限付きURLの背後でローカルディスク(またはマウントされたボリューム)に保存されます。

キューもワーカープロセスもRedisもありません。レンダリングは同期で、Chromiumがウォームアップされていれば通常100〜400msです。検出はホストごとに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)

認証

メソッド

パス

認証

説明

POST

/auth/register

-

メールアドレスでキーをリクエストします。常に202を返し、キーは受信トレイに送信されます。初回は新規ユーザー+無料クレジット付与。以降の呼び出しはローテーション(古いキーは24時間有効、その後失効)。

レンダリング

メソッド

パス

認証

説明

POST

/v1/documents

Bearerキー

マークダウン→PDF。theme、brandKit(保存名またはインライン)、brandFromUrl(検出して適用するショートカット)、css、format、landscape、titleを受け付けます。署名付きURLを返します。レンダリングされたページ数がMAX_PAGES_PER_RENDER(デフォルト200)を超えると413を返します。

GET

/pdf/:id?exp=&sig=

署名付きURL

PDFをストリーミングします

ブランドキット

メソッド

パス

認証

説明

POST

/v1/brand-kits

Bearerキー

名前を付けてキットを保存・更新します(ユーザーごとに名前で1つ)

GET

/v1/brand-kits

Bearerキー

自分のキットを一覧表示します

GET

/v1/brand-kits/:id

Bearerキー

1つのキットを読み取ります

DELETE

/v1/brand-kits/:id

Bearerキー

キットを削除します

POST

/v1/brand-kits/detect

Bearerキー

URLを渡すと、primaryColor、logoUrl、favicon、fontFamily、fontStyleを返します。24時間キャッシュされます。バイパスするには{ refresh: true }を渡します。

POST

/v1/brand-kits/detect-batch

Bearerキー

最大20 URLを既存のPlaywrightプールで並列処理します。アイテムごとのエラーはインラインで返されます。

アカウント / ヘルス

メソッド

パス

認証

説明

GET

/account

Bearerキー

メールアドレス、クレジット、アクティブなAPIキーの一覧(平文のためユーザーが復元可能)

GET

/health

-

{ status: 'ok' }

デモ(匿名・制限付き)

認証不要、クレジット消費なし。グローバルな毎分60回に加えて、IPごとの厳格なレート制限(毎時30回)があります。

メソッド

パス

説明

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

ユーザーのメールを結合したページネーション付きリスト。userId、statusでフィルタリング可能。

GET

/admin/documents/:id/pdf

署名付きURLなしで任意のPDFをストリーミングします

セキュリティ体制

  • APIキー: 192ビットのランダムで、平文で保存されます(/accountで表示できるようにするため)。失効は猶予期間付きのrevokedAtタイムスタンプを使用します。

  • 署名付きURL: HMAC-SHA256、exp + sigクエリパラメータ、デフォルトのTTLは7日間です。

  • SSRFガード: assertPublicUrlはDNSを解決し、呼び出し元が送信したURLのRFC1918、ループバック、リンクローカル、IPv6 ULAを拒否します。brandKit.logoUrlと/v1/brand-kits/detectに適用されます。送信されたホストが公開されていても、すべてのホップが公開されているとは限りません。公開ホストがプライベートアドレスにリダイレクトする可能性があるため、Playwrightのナビゲーションパス(src/render/detect.ts)と画像インライン化フェッチ(src/lib/inline-image.ts)の両方が、リダイレクトのたびにアドレスを再検証してからフォローし、最終ナビゲーション後にも再度検証します。これによりウィンドウは狭まりますが、完全には排除されません。リダイレクト先への最初の接続は再チェックが拒否する前に発生するため、意図的な攻撃者は、拒否されたターゲットからページコンテンツが抽出・レンダリングされることはなくても、ブラインドの送信リクエスト(レスポンスデータは返されない)を引き起こす可能性があります。完全に閉じるには、ネットワーク層でのIPピン留めが必要です。

  • CSSインジェクションガード: cssフィールドは<style>、</style>、<script>、</script>を拒否します。そうしないと、<style>${css}</style>内の生の埋め込みから、攻撃者が脱出してChromiumプールでJSを実行できてしまいます。

  • マークダウンのサニタイズ: remark-rehypeはallowDangerousHtml: falseで実行されるため、マークダウン本文内の<script>は削除されます。

  • 制限: マークダウン≤500KB、CSS≤50KB、本文≤2MB、レンダリング≤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を参照してください。REST API上の薄いMCPクライアントです。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を使用しています。このバージョンはplaywright npmパッケージと同期させてください。不一致があるとブラウザバイナリが存在せず、レンダリングに失敗します。

  • railway.jsonのstartCommandは意図的に省略されています。Railwayはこれをargv(シェルではなく)として解析するため、&&で連結されたコマンドは失敗します。DockerfileのCMDはsh -cでラップされ、完全な起動シーケンスを実行します。

  • メール: RESEND_API_KEYが設定されるまで、登録キーは標準出力にログ出力されます。[email:console]をgrepしてください。

例

これらはすべてsamples/にチェックインされています。scripts/preview.tsとscripts/detect.tsによって生成され、npx tsx scripts/preview.ts / npx tsx scripts/detect.ts <url>で自分で再生成できます。

同じマークダウン、5つのテーマ(以下は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 で使用)。

ステータス

実装済み: 5つのテーマでのmarkdown → PDF変換、URLからのブランドキット自動検出(serif|sans|mono の単純な分類ではなく、実際のfont-familyスタックを使用)、24時間の検出キャッシュ、brandFromUrl によるワンコールショートカット、バッチ検出(20 URLを並列処理)、WCAG輝度比ガード、メールベースのキー発行とローテーション猶予期間、MCPサーバー、読み取り専用の管理画面、署名付き共有URL、事前レンダリング済みドキュメント。

意図的に未実装: 決済処理、MCPパッケージのnpm公開、自動テスト、本番用Prismaマイグレーション。これはライブ運用中のバックログではなく、ポートフォリオ作品として完成したものであり、v1.0を目指して開発を続ける予定はありません。

ライセンス

MIT — LICENSE を参照。

Related MCP Connectors

Related MCP Servers