「動体視力テスト」という嘘の入口を守りながら、SEOとAdSense審査を通すパズル

初見の友達には動体視力テストと信じ込ませつつ、検索エンジンには視線計測ゲームの説明を見せるパズル。「検索する人はすでに知っている」という転換と、同日2回の改称の教訓。

#SEO#Next.js#設計

Webカメラの視線追跡(アイトラッキング)を使ったパーティーゲーム「Michaw(見ちゃう)」を開発していたとき、プロダクトの根幹に関わる1つの矛盾にぶつかりました。

このゲームは、友達から送られてきたURLを開くと、画面に**「動体視力テスト」**というもっともらしい案内が出るところから始まります。しかしそれは真っ赤な嘘で、実際にはインカメラで「画面上の誘惑(見てはいけないゾーン)に視線が吸い寄せられるまでの秒数」を0.01秒単位で測る、というドッキリ型のゲームです。

つまり、友達からリンクを受け取って初めてアクセスした人には、結果画面でネタバラシされるまで、ここが「真面目な動体視力テストのサイト」だと100%信じ込ませる必要があります。

ところが、サイトを公開して検索エンジン(SEO)に載せたり、Google AdSense の審査を通したりしようとすると、まったく逆のことが求められます。「このサイトは何をするサービスなのか」を数千字のテキストで詳しく説明し、プライバシーポリシーや運営者情報を明記し、どのページからでも迷わず辿れるナビゲーションを置かなければなりません。

「初めて来た人には嘘をつき通したい」のに、「検索エンジンと審査員には最初からすべてを正直に説明しなければならない」——このパズルをどうやって解いたのか、そして同じ日にサービス名を2回も改名して踏んだ手痛い教訓を記録しておきます。

偽装画面から徹底的に排除したもの

まず、最優先のルールとして「偽装が効いている画面(初見のトップ /、友達からの招待 /challenge、測定中の /play)」からは、ここが個人開発のジョークサイトだと勘づかれる要素を徹底的に排除しました。

要素偽装画面(初見 /・/challenge・/play)種明かし後の画面(2回目 /・/host・/ranking)
ヘッダーのロゴ小さな灰色の文字(text-sm)ピンクからシアンへ光る大きなグラデーション文字(text-2xl)
投げ銭ボタン(☕ 応援)完全に非表示ヘッダーと言語トグルの隣に表示
解説ページ(/about)へのリンク一切置かない(辿れる経路を作らない)フッターやメニューからリンク
ブラウザのタブ名(<title>)「動体視力テスト」に偽装「Michaw(見ちゃう)|視線で『見てしまうまでの時間』を測る」

たとえば、開発者を支援してもらうための「☕ 応援(Stripe Payment Links)」ボタンや、カラフルでポップなブランドロゴは、それ自体が「これは個人の作ったエンタメ製品です」と主張する調度品です。真面目な視力検査を装う画面にそんなボタンが1つでもあれば、勘のいい人はカメラを許可する前に気づいてしまいます。

さらに、ホストが友達にURLをコピーした瞬間には、自動で**「送り方のコツ」シート(SendTipsSheet)**がポップアップするようにしました。「『動体視力テスト』って言って渡す」「『これやってみて(笑)』の(笑)をつけない。リンクだけ貼る」という3箇条を、背景タップでは閉じられないモーダルで表示します。どれだけ画面の偽装を作り込んでも、送る人がチャットで「(笑)」と添えた瞬間にすべてが台無しになるからです。

発想の転換:「検索窓に名前を打つ人は、もう知っている」

ここまで偽装を守ると、トップページ(/)のHTMLには製品の説明文を1文字も置けなくなります。最初は「説明ページ(/about)を作ってしまうと、せっかく検索して来てくれた初見の人に種明かしを配ってしまうのではないか」と躊躇していました。

しかし、よく考えてみるとその前提自体が間違っていました。そもそも、Googleの検索窓に「Michaw」や「見ちゃう 視線」と打ち込む人は、SNSや友達経由ですでにネタを知っている人です。騙される側のユーザーは、検索ではなく「友達から直接送られてきたリンク」か「SNSのタイムラインに流れてきたURL」という扉から入ってきます。

守るべき扉はその2つだけであり、「検索」という3つ目の扉の前に立っている人は、むしろ「あれって何のサイトだっけ?」「カメラの映像はサーバーに送られないの?」という説明を探しています。

そう割り切ったことで、迷いなく /about に約1,500字の詳しい解説ページを作ることができました。「2つの遊び方」「何を測っているのか」「カメラと写真はどうなるのか(映像は端末内のブラウザでのみ処理され、サーバーには一切送信されないこと)」の4節に加え、7問のFAQを載せています。同時に、AdSense審査の必須要件であるプライバシーポリシー(/privacy)も新設し、カメラ処理の安全性やCookie・広告配信の仕組み、24時間で仕掛け画像が自動削除される仕様を明記しました。

クローラー用の内部リンクは /ranking に置く

ここで1つ技術的な罠がありました。/about や /privacy を作っても、初見のトップページ(/)には偽装のためフッターリンクを置いていません(2回目以降のアクセスで localStorage にフラグがあるときだけフッターが出ます)。つまり、Googleのクローラーがトップページを見に来ても、そこには /about へ辿るリンクが1本も存在しないのです。

そこで、最初から全文が静的HTMLとして出力され、かつそこを見ている人はすでにネタを知っている**ランキングページ(/ranking)**に /about への内部リンクを設置し、sitemap.xml にも優先度0.9で登録しました。

また、FAQのデータはページ本文の描画と、検索エンジン向けの FAQPage 構造化データ(JSON-LD)の両方で使い回しています。最初はクライアントコンポーネントである components/About.tsx からFAQ配列を export してサーバーコンポーネントの app/about/page.tsx で読み込んでいたのですが、Next.js では 'use client' モジュールからエクスポートされた値はサーバー側には「クライアント参照(Client Reference)」として届くため、ビルド時に FAQ_JA.map is not a function でクラッシュしました。中立な lib/faq.ts に切り出すことでビルドが通り、「Googleに見せる構造化データの回答」と「人間がページで読む回答」が絶対に食い違わない構成になりました。

SEOで踏んだ3つの勘違い:robots.txt と noindex と canonical

ページの構成を整えたあと、既存のSEO設定を総点検したところ、「良かれと思って書いていたけれど、実際にはまったく効いていなかった(あるいは逆効果だった)設定」が3つも見つかりました。

  1. robots.txt で Disallow したページに noindex を書いていた。 開発者向けの計測パネルである /debug と /gaze を検索結果から隠すため、robots.txt で Disallow しつつ、ページのメタタグにも noindex を入れていました。しかし、Disallow されたページはクローラーがそもそもHTMLを取得しに来ないため、その中に書いた noindex タグは永久に読まれません。結果として、URLだけが検索結果に残ってしまうことがあります。正しくは、robots.txt の Disallow から外してクロールを許可し、ページの noindex タグを読ませてインデックスから落とすことでした(期限付きの個人宛URLである /challenge と /trap だけを Disallow に残しました)。
  2. /play のタブタイトル偽装が検索結果に漏れていた。 ゲーム画面(/play)はプレイ中にタブ名を見られてもバレないよう、<title> を「動体視力テスト」にしていました。コードのコメントには「OGPタイトルを固定してあるので検索結果には影響しない」と書いてあったのですが、これは完全な誤りです。Googleの検索結果の見出しに最も強く使われるのは <title> タグそのものです。放置すると、本名のドメインの検索結果に「動体視力テスト」というページが並んでしまうため、/play を noindex に変更し、sitemap.xml からも外しました。
  3. noindex の子ページがルートの canonical: "/" を継承していた。 ルートのレイアウトで canonical: "/" を宣言していたため、個別指定を忘れた /debug や /gaze が「自分の正規URLはトップページ(/)です」と名乗りながら noindex を出していました。noindex と別ページへの canonical を同時に出すと、検索エンジンによっては「正規URLであるトップページ側も noindex なのだな」とシグナルが持ち越される危険があります。すべてのサブルートに必ず自分自身の自己 canonical を持たせるように修正しました。

同じ日に2回サービス名を改名し、発行済みリンクを全滅させた話

SEO対策の一環で、もう1つ大きな事件がありました。このアプリは最初、開発コードネームのまま mesen(目線ストップウォッチ)という名前で動かしていました。しかし一般名詞の組み合わせでは検索で1位を取りにくいため、造語に改名することにしました。

日本語の「見ちゃう」をもじって、最初は Michow という綴りを考え、コードベース全体を一括置換して Cloudflare Workers に michow.miiiwa.workers.dev としてデプロイし、旧 mesen Worker を削除しました。

ところがその数時間後、「Michow」はフランス語圏に実在する姓(苗字)であることが判明しました。検索窓に打ってみると、実在の人物や海外のページがずらりと並びます。造語にする最大のメリットは「その名前で検索すれば確実に自分のサイトが1位に出ること」なのに、既存の苗字と衝突しては名前を変えた意味がありません。

結局、その日のうちに読みは変えず綴りだけを Michaw に再改名し、michaw Worker を新規デプロイして michow Worker を削除しました(翌日には独自サブドメインの michaw.miiiwa.com へ移行しました)。

この「1日に2回の改名とURL変更」で痛感したのは、改名のコストはコード内の文字列置換ではなく、すでに世に出たURLに集中しているということです。Worker名を変えるたびに、それまでに発行された仕掛けリンク(URL)はすべて404になって死にます。さらに、一括置換したつもりでも、数日後にプライバシーポリシーのページを追加した際、改名前のブランチからコピーしてきたコード片に旧綴りの Michow が7箇所も再混入する事故も起きました。

「テストをスキップする」を本物の片道切符にする

最後に、初見の偽装画面(/)にもう1つだけ手を加えました。この画面では「次の画面でカメラの利用許可を求められます」と案内していますが、カメラを使いたくない人や、どんなサイトか中身だけを先に見たい人のために「スキップする」という小さなリンクを置いています。

最初は、スキップを押しても localStorage に何も書き込まず、「開き直せばいつでも最初の画面に戻れます」という案内にしていました。しかしそれだと、確認ダイアログの「スキップしますか?」が何も失わない軽い操作になり、読まずに押されてしまいます。さらに悪いことに、本人がスキップして種明かしを見たあとの端末を、隣にいる友達に「これやってみて」と渡したとき、リロードしただけで初見画面が復活してしまう(あるいは逆に、いつ戻ってしまうか分からない)という中途半端な状態になっていました。

そこで、スキップ時の確認文を「このままトップページに移動し、この画面には戻れません。この端末では、テストの案内は二度と表示されません」と明確な忠告に書き換え、実行時に localStorage.mesen.introSkipped = '1' を記録して、リロードしても二度と偽装の入口には戻らない完全な片道切符にしました(プレイ済みを表す mesen.seen とは別キーに分けることで、未プレイのままスキップした人であることは判別できるようにしています)。

「嘘の入口」を成立させるというのは、単に面白いトップページを1枚作ることではなく、検索エンジン、広告審査、SNSの共有カード、そしてブラウザのストレージ状態まで、「誰がどの扉から入ってきても矛盾しない動線」をパズルのように噛み合わせる仕事でした。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る