Marginalia

記事中のURLプレビューを実装した (Cloudflare Pages Functions)

記事中のURLプレビューをiframeで表示するためのエンドポイントをCloudflare Pages Functionsで実装した。実はこれまで長らくはてなブログのembed APIを勝手に借りていた。倫理的によろしくない面もあったり、パフォーマンスや信頼性の面でもセルフホストしたいと思っていたが、着手するのを先延ばしにしていた。別に技術的に困難なポイントがあったわけではないが、備忘録としてやったことを書く。

/embed エンドポイントの作成

このブログはCloudflare Pagesでホスティングしている。Cloudflare PagesのFunctions機能は、デプロイするレポジトリの /functions ディレクトリの中に配置したスクリプトを動的なWorker関数として呼び出せるようにしてくれる。

今回はこの機能を使って、 /functions/embed/index.tsx ファイルを配置することで、 https://blog.lacolaco.net/embed というエンドポイントを作成した。特にドキュメントには書かれていないが、 /functions ディレクトリ内に配置するエンドポイントのスクリプトは、 index.{js,jsx,ts,tsx} 全部対応しているようだ。特に何も設定せずとも index.tsx を配置すればデプロイできた。

// functions/embed/index.tsx

export function onRequest(context) {
  return new Response("Hello, world!")
}

メタデータの収集

プレビューを表示するには埋め込まれるページの情報を収集する必要がある。今回は最小限に、ページタイトルとfaviconを表示することにした。

URLからページタイトルを取得するには、一度HTTPリクエストを行ってページのHTMLを返却してもらう必要がある。普通にGETリクエストを送ると場合によってはJSONが返ってくるケースもあるので、 accept ヘッダでHTMLを明示的に要求している。

async function getPageTitle(url: string) {
  const response = await fetch(url, {
    headers: {
      'user-agent': 'blog.lacolaco.net',
      accept: 'text/html',
      'accept-charset': 'utf-8',
    },
  });
}

レスポンスのHTMLからタイトル情報を取り出すためにはHTMLをパースする必要がある。今回は使いなれている cheerio を使うことにした。

タイトル情報は <title> タグにある場合と、 meta[property=title] メタデータにある場合と、 meta[prooperty=og:title] メタデータに設定されている場合とがありえるため、 og:title を優先するようにした。本当は head>title というクエリにしたいのだが、実はAmazonの商品ページのHTMLを見ると <title> タグがBodyの中にある。仕様上は不正なのだがなぜかそれでも動いており、AmazonのURLを貼ることは少なくないので考慮する必要があった。たまにSVGの <title> タグにもヒットする可能性があるが、基本的にはメタデータのほうが存在するのでエッジケースと思って妥協している。

async function getPageTitle(url: string) {
  const response = await fetch(url, {
    headers: {
      'user-agent': 'blog.lacolaco.net',
      accept: 'text/html',
      'accept-charset': 'utf-8',
    },
  });
  const html = await response.text();
  const $ = load(html);
  const metaOgTitle = $('head>meta[property="og:title"]').attr('content');
  if (metaOgTitle) {
    return metaOgTitle;
  }
  const metaTitle = $('head>meta[name="title"]').attr('content');
  if (metaTitle) {
    return metaTitle;
  }
  const docTitle = $('title').text();
  return docTitle || url;
}

また、faviconについては、今回初めて知ったのだが https://www.google.com/s2/favicons というAPIを使うとGoogleがインデックスしている(?)favicon画像を返してくれる。これを使うことにしたので自前でのfavicon取得は行わなかった。

HTMLの組み立て

あまり真剣に選定したわけではないが、Workers環境でHTMLを組み立てるにあたって今回はPreactと preact-render-to-string を使うことにした。また、スタイリングのために Goober というライブラリを初めて使ってみた。特に難しいことはなく、普通のCSS-in-JSライブラリだった。レイアウトは Zenn のURLプレビューを真似している。

import { extractCss, setup, styled } from 'goober';
import { h } from 'preact';
import { render as renderPreact } from 'preact-render-to-string';

setup(h);

function buildEmbedHtml(title: string, url: string) {
  const hostname = new URL(url).hostname;

  const App = styled('div')({
    border: '1px solid #ccc',
    borderRadius: '8px',
    overflow: 'hidden',
  });
	
	//...

  const app = renderPreact(
    <App>
      <Link href={url} target="_blank" rel="noreferrer noopener nofollow">
        <LinkContent>
          <LinkTitle>{title}</LinkTitle>
          <LinkInfo>
            <LinkFavicon
              src={`https://www.google.com/s2/favicons?sz=14&domain_url=${url}`}
              alt={`${hostname} favicon image`}
              width="14"
              height="14"
            />
            <LinkURL>{url}</LinkURL>
          </LinkInfo>
        </LinkContent>
      </Link>
    </App>,
  );
  const style = extractCss();

  return `<!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width" />
        <title>${title}</title>
        <style>
          html, body {
            margin: 0;
          }
        </style>
        <style id="_goober">${style}</style>
      </head>
      <body>
        ${app}
      </body>
    </html>
  `;
}

という感じで最後にこれらを onRequest 関数で返すようにして完成した。

export const onRequest: PagesFunction<Env> = async (context) => {
  const url = new URL(decodeURIComponent(context.request.url)).searchParams.get('url');
  if (!url) {
    return new Response('Missing url parameter', { status: 400 });
  }
  const title = await getPageTitle(url);
  const html = buildEmbedHtml(title, url);
  return new Response(html, {
    headers: { 'content-type': 'text/html; charset=utf-8' },
  });
};

特別に工夫したところも、苦労したところもそれほどなく、思ってたよりも簡単で2,3時間でできてしまったので、もっと早くやっておけばよかったと反省した。あと、 wrangler pages dev コマンドでローカルでも Cloudflare Pages Functions がエミュレートできるのはとても開発者体験がよかった。今は記事のOGP画像はビルド時に全記事分生成しているが、ビルド時間が長いし読まれない記事の分も生成するのは電力の無駄なので、これもPages Functionsで動的に生成するように変えるかもしれない。

おわり。