🧘‍♂️

Zenn記事から“引用画像リンク”を作るChrome拡張を開発した(Cloudflare Workers+Hono活用)

に公開

はじめに

皆さんも今読んでいるこのZennで技術記事を読んでいると、「この部分、分かりやすい!」「後で参照したい!」と思う箇所がたくさんありますよね。
しかし、その部分を正確に引用して共有したり、メモとして残したりするのは意外と手間がかかります。

そんな課題を解決するためにChrome 拡張機能「zennquotes」を開発しました!
https://chromewebstore.google.com/detail/dfobifjnmfmcagiojipbdcpjbkemlhid?utm_source=item-share-cb

この拡張機能を入れた状態でZennの記事から引用したい部分を選択すると、引用情報を含んだ OGP 画像付きのリンクなどを簡単に生成することができます!
https://zennq.folks-chat.com/jvklz3f
https://zennq.folks-chat.com/0lr7p2s

使い方動画
https://www.youtube.com/watch?v=6Vx5hiDyiAU

本記事では、「zennquotes」との技術的な側面や、開発で工夫した点についてご紹介します。

きっかけ

皆さんご存知の電子書籍プラットフォームKindleにはkindle quotesという書籍の引用画像を作る機能があります。kindle quotesを使ったポストが目に付き、その引用元がとても気になりました。
https://www.lifehacker.jp/article/130206kindlequote/

そこで私の好きなZennにも引用元が気になるような、そんな共有の仕方があればいいなと思い開発しました。

仕組み

「zennquotes」は、これらの課題を以下の機能で解決します。

  1. Chrome 拡張機能による簡単操作:
    • Zenn の記事ページ (https://zenn.dev/...) で引用したいテキストを選択。
    • 右クリックメニューから「ZennQuotesリンクを作成」を選択して起動。
    • 選択された引用箇所と記事 URL がバックエンドに送信されます。(記事タイトルや著者情報はサーバー側で自動取得)
  2. zennquotes-serverと連携した OGP 画像生成:
    • 取得した引用情報(選択テキストと記事 URL)をバックエンド API (zennquotes-server) に送信。
    • サーバー側で引用テキスト、記事タイトル、著者情報を含む OGP 画像を動的に生成。
    • 生成された引用ページへの URL (https://zennq.folks-chat.com/{ID} 形式) を取得し、クリップボードにコピーします。この URL を SNS などで共有すると、引用情報を含む OGP 画像が表示されます。

これらの仕組みにより、数クリックで Zenn の記事の引用と共有が可能になります。

構成技術

「zennquotes」と「zennquotes-server」は使いやすさと開発効率を重視し、以下の技術スタックを採用しています。

zennquotes (フロントエンド - Chrome 拡張機能)

Zennの資料を参考に以下のリポジトリを改造する形で作成しました(参考資料は後述)。
https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite

書籍の中で取り上げられているサンプルプログラムが「開いているサイトの文章を選択する」「右クリックしてContextMenuをクリックする」DeepLクローンだったので、開発したかったものにぴったりでした。

zennquotes-server (バックエンド API)

特徴的な技術要素は以下です

  • プラットフォーム: Cloudflare Workers
  • データベース: Cloudflare D1
  • 画像管理: Cloudflare R2
  • 言語/フレームワーク: TypeScript + Hono
  • OGP画像生成ライブラリ: Satori + Resvg

バックエンドは Cloudflare のサーバーレス技術を全面的に採用しています。
Workers は Cloudflare が提供する超軽量なサーバーレス実行環境。数行書けばすぐ世界に公開できる、その手軽さが魅力です。
D1 は Cloudflare が提供する軽量な SQL 互換データベース。超シンプルに使えるのが魅力です。
R2 は Cloudflare のオブジェクトストレージ。S3 互換なのに転送コストゼロ、静的ファイルの保存にうってつけです。
これらにHonoやOGP画像生成ライブラリ(詳細は後述)を組み合わせることで、スケーラブルかつ低コストなインフラを実現しました!!

(ただ、画像生成が重いので結構簡単にCloudflare WorkersのCPUリミットが気になる...)

独自の技術的な工夫点

zennquotesのシーケンス図はざっくり以下です
所々に些細な工夫が仕込まれているので4つほど紹介させていただきます

Honoフレームワークによる効率的なAPI開発

Cloudflare Workers 上で動作する Web フレームワークとして Hono を使わせていただきました。
https://hono.dev/

Hono は TypeScript との親和性が高く、ルーティングやミドルウェアの記述が非常にシンプルです。これにより、OGP画像生成とOGP設定済みページ取得のAPI のエンドポイントを迅速かつ簡潔に実装できました。

// src/index.ts (抜粋)
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { generateOgpImage, fetchMetadata } from './ogp/generator'; // OGP生成ロジック

// ... (型定義など)

const app = new Hono<{ Bindings: Env }>();

// CORS設定
app.use('/quotes/*', cors());
app.use('/img/*', cors());

// 引用データを受け取り、OGP画像付きURLを生成するエンドポイント
app.post('/quotes', async (c) => {
  const { url, text: quote } = await c.req.json<QuoteRequest>();

  // Zenn記事からメタデータを取得
  const { title, author, authorAvatarUrl } = await fetchMetadata(url);

  // 取得したメタデータをデータベースに保存 (D1)
  const id = generateRandomId(7); // ランダムなIDを生成
  const stmt = c.env.DB.prepare(
    'INSERT INTO quotes (id, url, text, title, author, author_avatar_url) VALUES (?, ?, ?, ?, ?, ?)'
    );
  await stmt.bind(id, url, quote, title, author, authorAvatarUrl).run();

  const imageUrl = `${c.env.APP_URL}/img/${id}`; // OGP画像を表示するURL
  const quoteUrl = `${c.env.APP_URL}/${id}`; // 引用ページへのURL

  return c.json({ id, quoteUrl, imageUrl });
  }
});

// OGP画像を表示するエンドポイント
app.get('/img/:id', async (c) => {
  // D1からデータを取得し、generateOgpImage を呼び出す
});

// 引用ページを表示するエンドポイント (HTMLを返す)
app.get('/:id', async (c) => {
 // D1からデータを取得し、OGPタグを含むHTMLを生成する処理
});

export default app;

Cloudflare Workers上でのサーバーサイド画像生成 (Satori + Resvg)

OGP 画像の生成には、Vercel が開発した SatoriResvg の WASM 版 (@resvg/resvg-wasm) を組み合わせました。

この組み合わせはZennにもいくつかの記事があり参考にさせていただきました(参考資料は後述)!

  1. SVG テンプレート: Satori を使い、JSX ライクな構文で動的に SVG を構築します。引用文、記事タイトル、著者情報などを埋め込みます。
  2. フォントの扱い: 日本語表示に必要なフォントファイル (.ttf) は Cloudflare R2 にアップロードしておき、Worker 起動時に読み込んでキャッシュします。これにより毎回フォントをダウンロードするオーバーヘッドを削減しています。
  3. 絵文字対応: テキスト中の絵文字は、Satori の loadAdditionalAsset 機能と Twemoji を利用して SVG として埋め込み、どの環境でも正しく表示されるようにしています。
  4. PNG 変換: 生成された SVG 文字列を @resvg/resvg-wasm に渡し、WASM の力で高速に PNG 画像へ変換します。

この構成により、サーバーレス環境である Cloudflare Workers 上でも効率的に動的な画像生成を実現できました。

// src/ogp/generator.tsx (抜粋)
import satori from 'satori';
import { Resvg, initWasm } from '@resvg/resvg-wasm';
import wasmModule from '../vender/resvg.wasm'; // WASMモジュール

// ... (フォント読み込み、Twemojiヘルパーなど)

export async function generateOgpImage(
  quote: string,
  title: string,
  author: string,
  authorAvatarUrl: string | null,
  r2Bucket: R2Bucket
): Promise<Uint8Array> {
  const { fontSans, fontSerif, fontLogo } = await loadResources(r2Bucket); // R2からフォント取得

  if (!wasmInitialized) { // WASM初期化 (初回のみ)
    await initWasm(wasmModule);
    wasmInitialized = true;
  }

  const quoteFontSize = await calculateFontSize(quote, fontSerif, { family: '"Noto Serif JP"', weight: 700 }); // 最適なフォントサイズ計算

  // SatoriでSVGを生成 (JSXライクなテンプレート)
  const svg = await satori(
    <div style={{ display: 'flex', width: 1200, height: 630, /* ... */ }}>
      {/* ... 引用文、タイトル、著者情報などを配置 ... */}
      <div style={{ fontSize: `${quoteFontSize}px`, /* ... */ }}>{quote}</div>
      {/* ... */}
    </div>,
    {
      width: 1200,
      height: 630,
      loadAdditionalAsset: loadAdditionalAsset, // 絵文字用
      fonts: [ /* ... フォント設定 ... */ ],
    }
  );

  // ResvgでPNGに変換
  const resvg = new Resvg(svg, {});
  const pngData = resvg.render();
  const pngBuffer = pngData.asPng();

  return pngBuffer;
}

引用文の長さに応じた動的なフォントサイズ調整

引用文は最大200文字には制限していますが、それでも短いものから長いものまで様々です。
どんな文字数の引用文でも画像の見栄えを良くするため、指定された領域(高さ 300px)に収まる範囲で、できるだけ大きなフォントサイズで表示するよう調整するロジックを実装しました。

  1. 高さ計算: 特定のフォントサイズでテキストを描画した場合の高さを計算するヘルパー関数 (calculateTextHeight) を用意します。内部的には Satori を利用して、実際にレンダリングした場合の高さをシミュレートします。
  2. 二分探索: 最小フォントサイズ (32px) と最大フォントサイズ (80px) の間で二分探索 (binary search) を行います。
  3. 最適サイズ決定: 各ステップで試したフォントサイズでの高さを calculateTextHeight で計算し、高さ制限 (300px) を超えない最大のフォントサイズを効率的に見つけ出します。

これにより、引用文の長さに応じて自動的に読みやすいフォントサイズが適用され、生成される OGP 画像の品質が向上しました。
AtCoderで学んだ二分探索が初めて趣味に活きて感動しました 🥲

// src/ogp/generator.tsx (抜粋)

// 特定フォントサイズでの高さを計算するヘルパー
const calculateTextHeight = async (
  text: string,
  fontSize: number,
  fontData: ArrayBuffer,
  fontOptions: FontOptions,
): Promise<number> => {
  const node = ( /* ... SatoriでレンダリングするJSX ... */ );
  const svg = await satori(node, { /* ... Satori設定 ... */ });
  const heightMatch = svg.match(/height="([\d.]+)"/);
  return heightMatch ? parseFloat(heightMatch[1]) : -1;
};

// 二分探索で最適なフォントサイズを見つける関数
async function calculateFontSize(
  text: string,
  fontData: ArrayBuffer,
  fontOptions: FontOptions,
): Promise<number> {
  const maxFontSize = 80;
  const minFontSize = 32;
  const heightLimit = 300;
  let low = minFontSize;
  let high = maxFontSize;
  let bestFitFontSize = minFontSize;

  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    const heightAtMidFont = await calculateTextHeight(text, mid, fontData, fontOptions);

    if (heightAtMidFont <= heightLimit) {
      bestFitFontSize = mid;
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return bestFitFontSize;
}

Zenn記事メタデータの自動取得

ユーザーが引用したい Zenn 記事の URL を指定すると、サーバー側でその URL にアクセスし、HTML 内の <script id="__NEXT_DATA__"> タグを解析します。このタグには Zenn がページレンダリングに使用するデータ (Next.js の props) が JSON 形式で含まれており、ここから記事タイトル、著者名、著者アバター画像の URL を自動的に抽出します。

これにより、Chrome 拡張機能側でこれらの情報を取得・送信する必要がなくなり、拡張機能の実装をシンプルに保つことができました。また、ユーザーが手動で情報を入力する手間も省けます。

仕様変更により動かなくなる可能性もあるため、定期的にチェック&調整が必要です(Zenn愛で乗り切ります!!!)

// src/ogp/generator.tsx (抜粋)
export async function fetchMetadata(url: string): Promise<{ title: string; author: string; authorAvatarUrl: string | null }> {
  // URLからHTMLを取得
  const response = await fetch(url);
  const html = await response.text();

  // __NEXT_DATA__ スクリプトタグを探してJSONをパース
  const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json" nonce=".+?">(.+?)<\/script>/);
  if (!nextDataMatch || !nextDataMatch[1]) throw new Error('Could not find __NEXT_DATA__ script tag.');
  const nextData = JSON.parse(nextDataMatch[1]);

  // 必要な情報を抽出
  const articleTitle = nextData?.props?.pageProps?.article?.title;
  const authorName = nextData?.props?.pageProps?.user?.username;
  const authorAvatar = nextData?.props?.pageProps?.user?.avatarUrl;

  return { title: sanitize(articleTitle), author: sanitize(authorName), authorAvatarUrl: authorAvatar || null };
}

まとめ

「zennquotes」は、Zenn の記事を読む・共有する体験をよりスムーズにするための Chrome 拡張機能です。Cloudflare のサーバーレス技術とモダンなフロントエンド技術を組み合わせることで、手軽でありながら強力な引用機能を実現できたと思っています!

ぜひインストールして、日々の技術情報収集や共有にご活用いただけたらとても嬉しいです! フィードバックもお待ちしています!

参考資料

Chrome拡張開発
https://zenn.dev/alvinvin/books/chrome_extension/viewer/chapter01

Satori + Resvgでの画像生成
https://zenn.dev/spacemarket/articles/6af6864298e6c8
https://zenn.dev/minagishl/articles/5fd539d5562c86
https://zenn.dev/herp_inc/articles/ogp-image-playwright

Discussion