私のマーケットプレイスは Expo で書き出した React Native Web アプリです。すべての出品ページは、JavaScript が動くまでは script タグ1つと空の div でしかありません。人間には問題なくても、検索には致命的です。Google は JS を実行することもありますが、Bing は実行しませんし、ニュージーランドの韓国系コミュニティサイトにとって最も重要な Naver のクローラーは、間違いなく実行しません。
これは、そのページを見えるようにした話と、そのあと私の予想しなかった形でそれを台無しにしたキャッシュルールの話です。
クローキングではないダイナミックレンダリング#
対処法は古く、Google も認めています。クローラーを判定し、SPA が描いたはずのものと同じ内容を含むサーバーレンダリング HTML を返す。別の内容ではなく、同じ内容を、より早く。
そこで /tabs/used-good-view?id=... は分岐します。ブラウザには Expo のシェル。クローラーには本物のドキュメント。<h1>、事実を並べた <dl>(価格、状態、カテゴリ、地域、在庫、写真枚数、掲載日)、生成した alt 付きの写真ごとの <img>、本文の抜粋、そしてこのページが孤立しないよう兄弟出品8件へのリンク。
最後の項目が思っていたより効きました。内部リンクができる前は、約1,000件の出品ページに何もリンクしておらず、Search Console でクロールが1日7〜11リクエストまで沈んでいくのが見えました。ページを見つけられないクローラーは、その中身がどれだけ良いかを気にしません。
罠: ベンダー名はそのボットの名前ではない#
クローラー判定は user-agent のマッチで、ここで噛まれました。
韓国のプラットフォームは、会社名とまったく似ていない名前でクロールします。Naver のクローラーは Yeti。Daum は Daumoa。Kakao のリンクスクレイパーは kakaotalk-scrap。3つとも、ベンダー名ではありません。
一方で同じベンダーが、埋め込みブラウザを持つアプリを出しています。そのブラウザは本物のブラウザ UA の末尾にアプリ名を足して送ります。KakaoTalk のアプリ内ブラウザは KAKAOTALK/10.5.1。Kakao のスクレイパーは kakaotalk-scrap/1.0。
kakao でマッチすれば両方に当たります。私はまさにそれをやり、その結果リンクを開いた KakaoTalk・Naver アプリ・Daum アプリのユーザー全員がクローラー向けページを受け取りました。JavaScript もナビゲーションもログインもないページを。ドキュメントとしては良くできていて、人間にはまったく使えないものを。
修正は順序のルールで、ルールとして書き出す価値があります。
def is_crawler(request):
"""アプリ内ブラウザは決してクローラーではない。UA にそのベンダーの
ボットと同じ名前が入っていてもだ — だからこの判定が先に来る。"""
ua = request.META.get("HTTP_USER_AGENT", "")
if not ua or _INAPP_BROWSER_RE.search(ua):
return False
return bool(_CRAWLER_RE.search(ua))アプリ内ブラウザを先に見て、そちらが勝ちます。そしてクローラーのトークンは必ずボットを指す必要があります。ベンダーではなく。
# 韓国の検索エンジン。Naver は Yeti、Daum は Daumoa、Kakao のリンクプレビュー
# スクレイパーは kakaotalk-scrap としてクロールする。3つともベンダー名ではない。
r"yeti/|naverbot|daumoa|kakaotalk-scrap|kakao[a-z]*bot|"そして前段に Cloudflare を置いた#
オリジンはシドニーの共有 CPU マシン1台で、毎秒20〜30リクエストを支えます。Googlebot が歩く URL は約995本。その全部がシドニーまで到達していました。すべての URL で cf-cache-status が DYNAMIC だったからです。Cloudflare はルールが指示しない限り HTML をキャッシュしません。 ゾーンの約16%だけがキャッシュされ、肝心の部分は1つも入っていませんでした。
そこでクローラー向けページにキャッシュルールを。単純な話です。出品 URL の本文が1つではない、という点を除けば。本文は3つあります。
- HTML を求めるクローラーには、サーバーレンダリングされたページ。
- 人間には、Expo のシェル。
Accept: text/markdownを送るエージェントには、データベースから組み立てた Markdown。空の HTML シェルを変換しても空のドキュメントにしかならないからです。
オリジンは Vary: Accept を送り、user-agent に対する Vary はどのみち望み薄です。そしてここに、私が知らなかったことがあります。
Cloudflare は HTML では
Varyを無視します。 3つの本文が1つのキャッシュキーを共有します。
先に到着した本文がその URL を取り、同じエッジロケーションのその後の訪問者全員に対して、オリジンの s-maxage が許す1時間、さらに stale-while-revalidate の1日ぶん、それが出続けます。
本番で両方向とも観測できました。
- Naver アプリのユーザーがクローラー向けページで出品 URL を温め、そのあと素の iPhone Safari が HIT でそのページを受け取りました。JavaScript のないドキュメントを眺める人間の出来上がりです。
- 人間が Expo シェルで URL を温め、Googlebot が空のシェルを受け取りました。SEO の取り組み全体が防ごうとしていたまさにその失敗が、今度はより速く、エッジから提供されたわけです。
絞ることで直すルール#
Cloudflare に HTML で Vary を尊重させることはできません。できるのは、3つの本文のうち1つだけがキャッシュに入る資格を持つようにすることです。
(starts_with(http.request.uri.path, "/tabs/")
and not any(http.request.headers["accept"][*] contains "text/markdown")
and (lower(http.user_agent) contains "googlebot" or ...)
and not (lower(http.user_agent) contains "kakaotalk/" or ...))クローラーの UA だけにマッチさせ、Markdown リクエストは除外します。これで人間のリクエストはどのルールにもマッチせず、以前どおり DYNAMIC を読んでオリジンへ行きます。キャッシュを読み書きするのはクローラーのリクエストだけで、そこに入りうる本文はクローラー向けの1つだけです。
エッジ TTL は respect_origin。寿命はダッシュボードに固定されるのではなく、ページをレンダリングするコードが持ち続けます。
見落としやすい運用上の細部を1つ。ルールを絞っても、すでにキャッシュにあるものは消えません。 パージが必要です。さもないと、たった今生成を止めたばかりの汚染されたエントリを配り続けることになります。
2つのリストと、その誤りが対称でない理由#
このルールにはクローラー UA のリストが必要で、Django にはすでに1つあります。同じ知識が2つのシステムに2部あるのは普通なら臭いますが、ここで面白いのは、両者がずれたときに何が起きるかです。対称ではないのです。
- Cloudflare 側のボットリストは、Django 側の部分集合であり続ける必要があります。 Cloudflare がボットだと思い Django はそう思わないトークンがあると、クローラー専用のキーの下に SPA シェルがキャッシュされ、それが本物のクローラーに出ていきます。
- アプリ内ブラウザの除外リストは、Django 側の上位集合である必要があります。 除外しすぎても、失うのはキャッシュヒットだけです。
どちらも安全な方向に間違えれば、キャッシュが少し減るだけ。1つ目を逆方向に間違えれば、また Googlebot に空のページを返すことになります。ちなみにこの非対称性こそ、リストが正規表現ではなく contains を使う理由でもあります。matches は Business プランが必要で、既知の安全な方向に大雑把でよいリストには contains で十分だからです。
ページではなくビルドを落とす不変条件#
クローラー向けページには、7日間有効な署名付き画像 URL が埋め込まれています。ページ自体はエッジで1時間、加えて stale-while-revalidate の1日。
この2つの数字は関係していて、キャッシュ寿命が画像の寿命を超えた瞬間、エッジは全写真が403になるページを配り始めます。それを知らせるアラートはありません。HTML 自体は正常なのですから。
そこで、この定数を持つモジュールは、関係が壊れていればインポート自体を拒否します。
CRAWLER_IMAGE_TTL = 7日
# s-maxage + stale-while-revalidate >= CRAWLER_IMAGE_TTL なら import 時に raise別々のファイルにある2つの定数のあいだの制約は、コードレビューでは見えず、プロセス起動時には明白です。プロセスが起動する場所に置きましょう。
Cloudflare がやったことのうち、取り消す必要があった2つ#
Vary: Origin がバンドルをキャッシュから締め出していました。 django-cors-headers は触れたすべてのレスポンスに Vary: Origin を刻み、Cloudflare は Accept-Encoding を超えて vary するレスポンスをキャッシュしません。その結果、クリティカルパス上で最大の 1.6MB の JavaScript バンドルが、まるごと静かにエッジから除外されていました。対処はミドルウェアをリストの先頭に置き、出ていく側で最後に走らせて、/_expo/ のアセットパスの Vary を書き換えることです。
エッジでの圧縮はオリジンでの圧縮より悪かった。 Cloudflare にその場で Brotli をやらせると 2,041,742 バイト、オリジン自身の gzip は 1,658,115 バイトでした。何も考えなくていい代わりに23%大きい。オンザフライ圧縮は圧縮率ではなく CPU を最適化します。オリジンからの事前圧縮済みアセットが勝ちます。
クロールを待たずに検索エンジンへ知らせる#
プロトコルは2つ、そして2つ合わせてもカバー範囲は残念なものです。
IndexNow は ping を1つ受け取り、Bing、Yandex、Seznam をカバーします。Google と Naver は参加していません。post_save からデーモンスレッドで動き、タイムアウトは5秒、売却済み・終了済みの出品はスキップし、キーがなければ何もしません。ネットワーク障害は警告を残すだけで、それを引き起こした保存を決して失敗させません。ベストエフォートのサイドチャネルに許される唯一の振る舞いです。
Google の Indexing API はちょうど2つのスキーマ型しか受け付けず、その1つが私の持っている JobPosting です。生きている求人は URL_UPDATED、終了したものは URL_DELETED。その削除通知が誠実でいられるのは、終了した出品が noindex も出しているからで、コードにそう書いてあります。あとで noindex を外す人が、ほかに何を壊すのかを知れるように。
そこで2回、時間を使いました。クォータは1日200 URL で、スクレイパーのインポート実行はシグナルを数百回発火させます。だからインポートされた出品はスキップします。さもないと1回のインポートで1日分の予算を使い切ります。そしてエンドポイントはコロン入りの urlNotifications:publish です。スラッシュを使うと Google はただの HTML 404 を返し、それは API が無効になっているのと見分けがつきません。
構造化データが教えてくれたこと#
3つのルール、どれも Search Console に叱られて覚えました。
- パースできないものは出さない。 給与は自由テキストで届きます。以前は生の文字列を
baseSalary.valueとして出していて、それは無効で、すべての求人に単位欠落のフラグが立ちました。今はパースできない給与は単に存在しません。 - スキーマを満たすためにフィールドをでっち上げない。 求人広告に番地が載ることは稀です。
PostalAddressにもっともらしいものを入れたくなります。求人広告のでっち上げの住所は、不完全な住所より悪い。だから地域と国だけを入れます。 - 有効期限は任意ではない。 Google は
validThroughのないJobPostingを落とします。だから1つ導出し、古い出品が昨日期限切れになったと主張しないよう下限を置きます。
そしてサイトマップについて4つ目。すべてに changefreq: daily を主張しない。 1,000件の出品を毎日再クロールしてくれという要求は、過負荷のホストとして読まれ、Google はサイト全体のクロールレートを下げることで応じます。実際にどれだけ最近変わったかから導出しましょう。
サイトマップには品質の下限もあります。タイトルと本文を合わせて40文字。Search Console が約1,000件を「クロール済み – インデックス未登録」と報告していて、ある中古品ページの固有コンテンツは、一語のタイトルと電話番号だけでした。40文字は意図的に低くしてあります。484件の出品のうち471件が韓国語で、韓国語は1文字あたりの情報量が多いからです。英語に合わせた閾値なら、マーケットプレイスの半分が落ちていたでしょう。薄い出品もインデックスページ経由でクロール可能なまま残り、誰かが編集すれば自動的にサイトマップへ戻ります。
ここから持ち帰るもの#
- 1つの URL で異なる本文を返すことは、何よりもまずキャッシュキーの問題です。 あなたの CDN が何をキーにしているかを突き止め、そのコンテンツタイプで実証するまでは
Varyを無視すると想定してください。 - 2つのシステムが1つのリストで一致しなければならないなら、どちら向きの誤りが安全かを見極め、それを両方の写しの隣に書いてください。「ずれてよい、ただし許されるずれ方はこれだけ」のほうが、ずれない前提より優れています。
- ボットはベンダー名ではなくボット名でマッチさせること。 特に英語圏の外では、そのベンダーがユーザーの手元のブラウザも作っています。
- 離れた定数のあいだの不変条件は、大きな音で失敗する場所に置くこと。 インポート時はタダで、誰もスキップできません。

