Flutter Webのブログをやっとインデックスさせた話
はじめに
以前、Flutter webで行ったSEO対策 という記事を書きました。metaタグの設定や Title ウィジェットによるページごとのタイトル変更、サイトマップの出力と、一通り対応したつもりでいましたが、その後も記事の詳細ページはインデックスされないままでした。
トップページは検索に表示されることがあるのに、記事の詳細ページは Search Console で確認すると「クロール済み - インデックス未登録」の状態が続いていました。しばらく手をつけていなかったのですが、原因を調べてみたところ、Flutter Web の SEO で最もつまずきやすい部分に引っかかっていました。
なお、最終的にうまくいった構成を紹介しますが、Google のクロールやレンダリングの挙動については、私自身も正確に把握しきれていない部分があります。あくまで私の環境で有効だった対応として読んでいただければと思います。
何が起きていたのか
前提として、このブログは記事を Markdown で書き、自作の Dart スクリプトで HTML やサイトマップに変換してから公開しています(このあと登場する make_html.dart などがそのスクリプトです)。
前回の記事の時点で、記事の URL に対しては本文入りの静的 HTML を返すようにしていました。Markdown を HTML に変換するスクリプトで本文入りの HTML を生成し、Firebase Hosting の rewrites で /blogs/xxx に配信する構成です。
{
"source": "/blogs/dart_import",
"destination": "/html/dart_import.html"
}
HTML 自体は本文入りで返しており、curl で取得しても本文は含まれています。それにもかかわらずインデックスされない、という状況でした。
原因は、Search Console の URL 検査で「クロール済みのページ」を確認して、ようやく分かりました。Google はクロールしたあと、レンダラー(Chrome ベースのもの)で JavaScript を実行してからページを評価します。このブログの HTML は flutter_bootstrap.js を読み込んでいたため、Google のレンダラー上でも Flutter が起動します。そして起動が完了したあとの DOM は、本文が取り除かれて canvas だけが残った、実質的に空のページになっていました。
つまり、自分で用意した本文入りの HTML を、自分で読み込んだ Flutter が消してしまっていた、というわけです。静的 HTML を返していても、レンダリング後に空になれば Google はそちらを評価します。ここが今回の最大の原因でした。
クローラーには Flutter を起動させない
対応自体はシンプルで、クローラーのときだけ flutter_bootstrap.js を読み込まないようにするだけです。いわゆる dynamic rendering(動的レンダリング)という手法で、bot には静的 HTML をそのまま見せ、人間のブラウザにだけ Flutter を起動させます。
make_html.dart が生成する HTML の中で、User Agent を見て処理を分岐しています。
(function () {
if (/bot|crawler|spider|slurp|bingpreview/i.test(navigator.userAgent)) return;
// ここから下は人間のブラウザだけ通る
var script = document.createElement('script');
script.src = 'flutter_bootstrap.js';
script.async = true;
document.body.appendChild(script);
})();
bot の場合は早期 return して何もしないため、本文の <main> がそのまま残ります。人間のブラウザの場合は、従来どおり Flutter が起動してアプリとして表示される、という分岐です。
ここで気になるのが、クローラーと人間で見えるものが変わるため、クローキング(人間と検索エンジンに異なる内容を見せる不正行為)に該当しないか、という点です。
私の理解では、これは規約違反ではないと考えています。bot にも人間にもまったく同じ HTML・同じ本文を配信しており、異なるのは Flutter を起動して描画するかどうかだけだからです。Google が問題とするクローキングは「検索エンジンにだけ都合のよい別の内容を見せる」ケースであり、今回は見せている情報そのものは一致しているため、意図的に欺いているわけではない、という整理です。
とはいえ、この点に確信があるわけではなく、規約が変わる可能性もあります。もし違反にあたると考えられる方がいれば、ご指摘いただけると助かります。
人間側のちらつきをどうにかする
これで bot 側は解決しましたが、今度は人間のブラウザ側で別の問題が出ました。Flutter が起動する前の一瞬、素の静的 HTML(黒背景の pre や、スタイルの当たっていない本文)が見えてしまう、いわゆる FOUC(スタイル未適用によるちらつき)です。
そこで人間のブラウザでは、Flutter を読み込む前に静的コンテンツを隠し、スピナーを表示するようにしました。
var hide = document.createElement('style');
hide.textContent = 'body{background:#fafafa}#static-content{display:none}';
document.head.appendChild(hide);
// スピナーを出す
var loading = document.createElement('div');
loading.id = 'static-loading';
document.body.appendChild(loading);
window.addEventListener('flutter-first-frame', function () {
document.getElementById('static-content')?.remove();
loading.remove();
hide.remove();
});
flutter-first-frame イベント(Flutter の初回描画が完了した合図)を受け取ったら、静的コンテンツとスピナーを取り除き、表示を Flutter に切り替えます。
注意したいのは、Flutter が何らかの理由で起動しなかった場合に、静的コンテンツを隠したまま真っ白な画面で固まってしまう点です。そのため、10 秒経っても first-frame が来なければ、静的コンテンツを表示するフォールバックを入れています。
setTimeout(function () {
if (done) return;
loading.remove();
hide.remove(); // 静的コンテンツを見せる
}, 10000);
コンテンツを隠す処理と、その保険となるフォールバックはセットで用意しておく必要がある、というのが今回の学びでした。
トップページの内部リンクとサイトマップ
詳細ページ単体をインデックスさせることとは別に、Google にサイト構造を伝えるために2つの対応を追加しました。
1つ目は、トップページへの記事一覧の埋め込みです。トップの index.html にマーカーを仕込んでおき、変換スクリプトがそこに全記事へのリンク付き一覧を静的 HTML として差し込みます。これにより、bot がトップページから各記事へたどれる内部リンクができます。
<!-- static-content:start -->
<!-- ここに記事一覧が生成される -->
<!-- static-content:end -->
2つ目は、サイトマップの lastmod の扱いです。以前は生成のたびに現在時刻を入れていましたが、内容が変わっていないのに更新日時だけが動くと、Google が lastmod を信用しなくなるとされています。そこで、最新記事の日時を使うように修正しました。
// 生成時刻ではなく最新記事の日時を使う
final latest = metaDataList
.map((e) => e.createdAt)
.reduce((a, b) => a.isAfter(b) ? a : b);
地味な対応ですが、実態と異なる更新日を出さないという意味では重要だと考えています。
まとめ
今回行った対応をまとめると、次のとおりです。
- クローラーには
flutter_bootstrap.jsを読み込ませず、本文入りの静的 HTML をそのまま見せる - 人間のブラウザでは従来どおり Flutter を起動し、その前に静的コンテンツを隠してスピナーを表示する(10 秒のフォールバック付き)
- トップページに記事一覧を静的 HTML で埋め込み、内部リンクを作る
- サイトマップの
lastmodに最新記事の日時を使う
最も効果があったのは、最初の「bot には Flutter を起動させない」対応です。これを入れてしばらくすると、詳細ページが順にインデックスされ、検索結果にも表示されるようになりました。
最後に
前回の記事では「Flutter Web でも一通り SEO 対策はできそう」と書いていましたが、実際には肝心のインデックスまで届いていませんでした。今回ようやくそこに手が届いた形です。Search Console の「クロール済みのページ」を自分の目で確認できたことが突破口になり、推測だけで対処せずに済みました。
SEO を重視するなら HTML で構築すべき、という公式の見解はそのとおりだと思います。それでも Flutter で書きたいという方の参考になれば幸いです。同じように詰まっている場合は、まず URL 検査でレンダリング後の DOM がどうなっているかを確認してみることをおすすめします。