HA710の個人ブログです。


Astroでブログを(ほぼ)ゼロJSにする

昨年にこのブログを Astro を使って構築した。Astro はあらかじめページをビルドしておくことで,閲覧者がアクセスした際に既存の静的アセットを提供するだけで完結できるような構築を可能としている「SSG (Static Site Generation)」と呼ばれる手法を採用したフレームワークであり,その背後には「デフォルトでゼロ JS」という設計思想がある。閲覧者がウェブサイトにアクセスしたとき,余分な JavaScript がダウンロードされて各ユーザの手元で実行されるよりも,あらかじめレンダリングして用意された HTML と CSS だけを読み込むほうがずっと良い——文章にしてみれば至極当たり前のことである。

しかし,このブログを作った頃にはそれらがきちんと徹底されていなかった。もちろん考えていた他の手法に比べれば(Astro を使うというだけで)ずっと JS を排除することができていたのだが,それでもユーザの手元で実行される JS はまだ残っていた。そこで,それら JS をさらに減らすようにいくつかの改修を加え,特に「外部サイトの埋め込みのビルド時での静的生成」を行った。

Astroを選ぶ理由
Astroを選ぶ理由

Astroは、ブログやマーケティング、eコマースなど、コンテンツ駆動のウェブサイトを作成するためのウェブフレームワークです。Astroがあなたのウェブサイトに適しているであろう理由について学びます。

 docs.astro.build

個人サイトをAstroで作り直し,ブログを開設した - 海の見える駅舎
個人サイトをAstroで作り直し,ブログを開設した - 海の見える駅舎

このサイトを改修し,Astroを使って再構築し,ブログを設置した。

 ha710.com

「外部サイトの埋め込みのビルド時での静的生成」とはどういうことか? 外部のウェブサイトや SNS に投稿された情報を自分のブログに埋め込みたいことがあるだろう。しかし,各 SNS 等が提供する埋め込み API は大抵の場合 JS を読み込むことで iframe がロードされて投稿が埋め込まれる形式であり,SSG とは正反対の思想を持っている。Google Chrome の開発者ツールから利用可能な Lighthouse のスコアを見ても,やはり埋め込みのせいでページ全体の読み込みに時間がかかったり,ページの読み込みがおおむね完了してから iframe がロードされるせいでレイアウトシフトが起こるなどの問題がある1。表示の高速化を考えれば,このような埋め込みは閲覧者がアクセスするたびに JS を動かすのではなく,極力事前にレンダリングしておくべきだ。

実は,一部の SNS などについては Astro Embed という便利な Astro コンポーネントがあり,これを利用すれば楽にプリレンダリングされた埋め込みが使える。例えば YouTube の埋め込みをしたい場合,Astro で

---
import { YouTube } from 'astro-embed';
---
<!-- body 要素内で -->
<YouTube id="dQw4w9WgXcQ" />

と書くだけで,以下のように YouTube のサムネイルが表示される(画像なのでそこまで重くない)。そしてこれがクリックされた段階で初めて JavaScript がロードされ,本来の埋め込みが動作する。よくできていて便利である。

Play
Astro Embed
Astro Embed

High performance, rich media embed components. For your site, from the Astro community.

 astro-embed.netlify.app

しかし Astro Embed も万能ではない。Twitter のツイートを埋め込む機能もあるが画像は表示してくれないし,ニコニコ動画の埋め込みに至ってはそもそも存在しない。それでも Twitter のツイートやニコニコの動画は自分のブログによく埋め込みたいものなので,いっそのこと自前でコンポーネントを作ることにした。ブログの記事は MDX で書いており,Astro コンポーネントをそのまま読み込める(本当は remark や rehype をいじれば MDX を使わずとも素の Markdown からコンポーネントに自動変換したりできるのだが,面倒なのでやめた)。

Twitter (X)

大昔の Twitter API は一定の制限下で無料で使える天国のようなツールだった。Twitter がイーロン・マスクに買収されて X に名前を変えたりしていた頃は,無料の API がどんどんサービスを終了していった。X API に名前を変えた Twitter API は月額 200 ドルなどというとてつもない金額の支払いを要求しており,一般の人間が容易に触れないものになってしまっていた。

しかし,2026 年に入ってから X API が従量課金制に移行の様子を見せており,現在では従量課金制が中心となっている。その利用料金も個人的な利用の範囲内ではとても安く,例えばツイートひとつを読むのにかかる料金は 0.005 ドル,ユーザひとりの情報を取得するのにかかる料金は 0.01 ドルになっている。既に取得済みの情報は手元でキャッシュできることも考えると,これはブログをビルドするのにはあまりにも安い2。利用料金はあらかじめチャージしておいたトークンから支払われる方式で,最低チャージ額は 5 ドルである。

X API pay-per-usage pricing and credits - X
X API pay-per-usage pricing and credits - X

The X API uses pay-per-usage pricing with no subscriptions. Buy credits, set spending limits, and earn up to 20% back in free xAI API credits.

 docs.x.com

X API は @xdevplatform/xdk という npm パッケージを公式提供しており,これを使うことで Lint などが効いた状態でコードを書ける。X Developer Console に登録をするとベアラートークンが発行されるので,それを渡すことでツイートの情報などが得られる。以下はツイート ID3id であるツイートに対して,投稿日時,投稿者のユーザ名・スクリーンネーム・プロフィール画像・UUID,さらに添付されたメディアに関する様々な情報を取得する TypeScript のコードである。

import { Client } from '@xdevplatform/xdk';

const client = new Client({ bearerToken: MY_XAPI_BEARER_TOKEN });
const response = await client.posts.getById(id, {
    postFields: ['created_at', 'text'],
    expansions: ['author_id', 'attachments.media_keys'],
    userFields: ['username', 'name', 'profile_image_url', 'id'],
    mediaFields: ['url', 'preview_image_url', 'type', 'variants', 'width', 'height', 'alt_text'],
});

if (response.errors) {
    // error handling
}
const postText = response.data!.text!;
const postDate = new Date(response.data!.createdAt!);
// ...

こうして得られたデータをもとに,ツイート埋め込みのカスタムコンポーネントが作れる。以下のように表示される。

HA710
HA710
@8_7_10

ウェブサイト整備のために今更 Astro 7 を触っているけど,Astro 5 の頃からアップデートしてなかったせいで新要素だらけだ

- View on Twitter

ニコニコ動画

Twitter と異なり,ニコニコ動画には公式の API が存在しない。ここでは詳しく述べない(各自で調べてほしい)が,nicovideo.jp ドメイン上に非公式の API が存在し,これを利用させてもらっている。

アプローチとしては,先に示した YouTube の埋め込みと変わらない。非公式 API を利用すればあらかじめタイトル・投稿者名・投稿者アイコン・サムネイルなどが取得できるので,これをもとにサムネイルを主体とした埋め込みのように見えるものを作る(これはただの画像なので最適化できて軽くない)。サムネイルがクリックされたら,サムネイル等を非表示にして代わりに iframe をロードする。元の埋め込み要素はページ読み込み時に JavaScript をロードしていたが,こちらはクリックしてからロードされるのでページ読み込みが高速になるのが利点である4

自動再生

Astro Embed の YouTube 埋め込みではサムネイルをクリックすれば埋め込み要素が自動的に再生されるが,ニコニコ動画の埋め込み API には自動再生に関するオプションがない。このため,普通に読み込むとまずサムネイルをクリックした時点で埋め込みの iframe が読み込まれ,そこからさらにもう一度クリックしなければ動画は再生されない。これは不便であるし,非直感的なので閲覧者に優しい設計でもない。

実は,(これまた非公式 API ではあるが)ニコニコ動画の埋め込みページは window.postMessage() が使える。これを利用すれば,サムネイルがクリックされた時点で自動再生することができる。上で述べた非公式 API に比べてかかる負荷が小さいと予測されるのでここでもその詳細を書きたい(なお,下に示すリンク先にニコニコ埋め込みの postMessage の詳しい仕様がある)。

ニコニコ動画埋め込みプレイヤーAPI非公式リファレンス
ニコニコ動画埋め込みプレイヤーAPI非公式リファレンス

 zenn.dev

もともとの埋め込み API は https://embed.nicovideo.jp/watch/sm**** のような形をしている。埋め込み API をそのまま使う(script 要素を挿入すること)場合に使われる iframe の URL は

https://embed.nicovideo.jp/watch/sm****?persistence=1&oldScript=1&referer=https://example.com&from=0&allowProgrammaticFullScreen=1

であるが,これのサーチパラメータに jsapi=1playerId=**** を設定する(playerId には好きな文字列を設定すればよい)。今回はニコニコ動画埋め込みプレーヤーの自作コンポーネントとして NicovideoEmbed.astro を作成した。

---
interface Props {
    readonly id: string;
}
const { id } = Astro.props;
const uID = getUniqueId();
---
<div data-nicovideo-id={id} data-unique-id={uID} tabindex="0">
    <!-- ... -->
</div>
<a href={'https://www.nicovideo.jp/watch/' + id} target="_blank">→ニコニコ動画で再生する</a>

なお,ユニークな ID を uID としてビルド時に生成し,data属性に格納している(これは,後述する理由により playerId に一意の文字列を割り当てる必要があるからである)。

iframe がロードされた時に自動的に再生するには,iframe がロードされてから postMessage の第一引数に以下のようなオブジェクトを格納して送ればよい:

{
    eventName: 'play',
    sourceConnectorType: 1,
    playerId: ****,
}

実際に,埋め込みコンポーネントがクリックされた時のイベントリスナで以下のように処理を行っている(playerId は data 属性から取得した一意な文字列を指定した)。

parentElement.addEventListener('click', () => {
    // 既存のサムネイル等を非表示にする処理をし,id と uID を取得しておく ...
    const iframe = document.createElement('iframe');
    iframe.allowFullscreen = true;
    iframe.allow = 'autoplay';
    iframe.width = '720';
    iframe.height = '480';
    const iframeURL = new URL(`https://embed.nicovideo.jp/watch/${id}`);
    iframeURL.searchParams.append('persistence', '1');
    iframeURL.searchParams.append('oldScript', '1');
    iframeURL.searchParams.append('referer', 'https://ha710.com/');
    iframeURL.searchParams.append('from', '0');
    iframeURL.searchParams.append('jsapi', '1');
    iframeURL.searchParams.append('playerId', uID);
    iframeURL.searchParams.append('allowProgrammaticFullScreen', '1');
    iframe.src = iframeURL.href;
    iframe.style.border = 'none';
    parentElement.appendChild(iframe);

    iframe.addEventListener('load', () => {
        iframe.contentWindow?.postMessage({
            eventName: 'play',
            sourceConnectorType: 1,
            playerId: uID,
        }, 'https://embed.nicovideo.jp');
    });
});

こうすることによって,クリックした時に自動的に再生できる。

デバイス規制動画への対処

いわゆる「デバイス規制」5がかかっている一部の動画は,埋め込みから再生しようとしても「この動画はニコニコ動画でのみ視聴できます」と表示され再生できない(代わりに動画ページへのリンクが表示される)。このときに自動的に新しいタブで動画ページを開く機能をつけるため,ニコニコ動画から飛んでくる postMessage も受信することにする。このメッセージは

{
    origin: 'https://embed.nicovideo.jp/',
    data: {
        data: { ... },
        eventName: string,
        playerId: string,
        sourceConnectorType: number,
    },
    // ...
}

という形で送られてくるようである。特に,デバイス規制がかかった動画を埋め込みから再生しようとして失敗した場合は,2 つあるうち内側のほうの data

data: {
    code: 'sensitive_video',
    message: 'この動画はニコニコ動画でのみ視聴できます。',
    raw: undefined,
}

という形になっている。playerIdiframe の URL で指定したものと同じになっており,先ほど述べたようにこれは一意の ID を指定しているので,これをもとに当該埋め込みの動画 ID を取得することができる。その動画のページをニコニコ動画で開くようにすればよい。

window.addEventListener('message', event => {
    if (event.origin === 'https://embed.nicovideo.jp') {
        if (event.data.data.code === 'sensitive_video') {
            const playerId = event.data.playerId;
            const playerWrapper = document.querySelector(`[data-unique-id="${playerId}"]`);
            if (playerWrapper) {
                const smID = (playerWrapper as HTMLElement).dataset.nicovideoId;
                smID && window.open(`https://www.nicovideo.jp/watch/${smID}`);
            }
        }
    }
});

最終的には以下のような埋め込みができる。

新・豪血寺一族 -煩悩解放 - レッツゴー!陰陽師 - ニコニコ動画
 
中の
新・豪血寺一族 -煩悩解放 - レッツゴー!陰陽師
→ニコニコ動画で再生する

外部画像の最適化(おまけ)

ツイートに含まれる画像やニコニコ動画のサムネイルなどは,多くは JPEG や PNG の形式で公開されており,またこのブログでの本文部分の横幅よりも大きなサイズで提供されている。また,これまで言及してこなかったが,ブログ内にいくつか設置されているリンクカードもリンク先の OGP 画像を表示しており,これらもサイズが大きい。これらの画像を直接 img タグの href 要素に指定すると効率が悪く,閲覧時にロードに時間を要する。理想的には,これらの画像は最適化すべきである。

Astro は <Image /> コンポーネントを利用することで内部画像の最適化(webp や avif などの次世代画像形式への変換や,ビューポートの幅に合わせたレスポンシブ画像の作成)を行ってくれる一方で,デフォルトでは外部画像を最適化しないが,明示的に設定を変更することで最適化可能である。例えば(Twitter の画像配信元ドメインである)pbs.twimg.com からの画像に関しては最適化する場合,astro.config.mjs

import { defineConfig } from 'astro/config';
export default defineConfig({
    image: {
        domains: ['pbs.twimg.com'],
    },
});

と書けばよい。SSL 化されているウェブサイトからの画像を最適化する場合は

import { defineConfig } from 'astro/config';
export default defineConfig({
    image: {
        remotePatterns: [{ protocol: 'https' }],
    },
});

となる。詳細は公式ドキュメントを参照されたい。

Images
Images

Learn how to use images in Astro.

 docs.astro.build

脚注

  1. YouTube やニコニコ動画などのような動画サイトの埋め込みであれば,通常は縦横の大きさがあらかじめ分かっているので,ロード前からその分の領域をあけておくことでレイアウトシフトを防げる(lazyload を設定した画像の縦横サイズを指定しておくようなものだ)。しかし,Twitter などのように横幅は指定できても縦の長さがわからないものはどうしようもない。

  2. 気まぐれなイーロン・マスクが X API の価格改定を行ったらどうなるか? それは今は考えない。

  3. ツイートの URL は https://x.com/USERNAME/status/POSTID のような構造をしているが,この POSTID の部分のこと。

  4. ただしページの読み込みが高速になる反面,クリックしてからの応答性は下がってしまう。

  5. センシティブな表現が含まれると判断された動画のこと。モバイル版を中心に閲覧制限などがかかったりする。

《おわり》

Twitter で共有する