---
title: 記事中のURLプレビューを実装した (Cloudflare Pages Functions)
slug: d12f0da2f01e
created_time: 2023-11-22T07:14:00.000Z
last_edited_time: 2025-06-11T08:38:00.000Z
category: Tech
tags:
  - 雑記
  - Cloudflare
published: true
locale: ja
---
記事中のURLプレビューをiframeで表示するためのエンドポイントをCloudflare Pages Functionsで実装した。実はこれまで長らくはてなブログのembed APIを勝手に借りていた。倫理的によろしくない面もあったり、パフォーマンスや信頼性の面でもセルフホストしたいと思っていたが、着手するのを先延ばしにしていた。別に技術的に困難なポイントがあったわけではないが、備忘録としてやったことを書く。

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

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

https://developers.cloudflare.com/pages/platform/functions/get-started/

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

```typescript
// functions/embed/index.tsx

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

## メタデータの収集

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

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

```typescript
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 を使うことにした。

https://cheerio.js.org/

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

```typescript
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プレビューを真似している。

https://goober.js.org/

```typescript
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` 関数で返すようにして完成した。

```typescript
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で動的に生成するように変えるかもしれない。

おわり。