Web開発
適切なApp StoreにルーティングするQRコード
QR Codes That Route to the Appropriate App Store (matthuggins.com)
要約
著者は、名刺に印刷するQRコードで、ユーザーのデバイスに応じて適切なアプリストア(iOSならApp Store、AndroidならGoogle Play)またはウェブサイトに誘導する仕組みを構築しました。これは、QRコードが単一のURLしかエンコードできないという制約と、印刷物は後から更新できないという問題を解決するために、自身で管理するURLにQRコードを紐付け、サーバーサイドでUser-Agentヘッダーに基づいてリダイレクト先を決定する方式を採用しています。
全文翻訳
適切なApp StoreにルーティングするQRコード
2026年9月24日
typescript
node.js
native
ユーザーエクスペリエンス
ブログに戻る
今週初めにPokerNexusを発表した際、私は「印刷されたリンク用の別のショートリンクホストをデプロイする」と軽く触れました。
この記事では、そのホストが何をするのか、そしてなぜそのように構築されているのかを説明します。
目標はシンプルでした。
裏面に「ダウンロード」と書かれたQRコードが付いた名刺が欲しかったのです。
問題は、QRコードは正確に1つのURLしかエンコードできないのに、「アプリをダウンロード」はスキャンする人によって3つの異なる場所を意味することです。
iPhoneはApp Storeのリストに移動する必要があります。
AndroidフォンはGoogle Playのリストに移動する必要があります。
それ以外のすべて(ラップトップ、クローラー、識別できないデバイス)はウェブサイトに移動する必要があります。
印刷されたカードは、財布に入った後では更新できないため、そこに記載されているURLは、宛先が後で変更された場合でも機能し続ける必要があります。
両方の問題は同じ答えを指しています。
私が制御するURLを印刷し、サーバーが各リクエストをどこにルーティングするかを決定できるようにします。
カードはhttps://go.pokernexus.com/appを指します。
コードの設計
QRコード自体は、私のQRコードジェネレーターで作成しました。
これは完全にブラウザで実行され、プレーンなSVGまたはPNGをエクスポートします。これは印刷店が望むものです。
画面上よりも名刺では、いくつかの詳細がより重要になります。
短いURLは、より密度の低いコードを生成します。
そして、より密度の低いコードは、名刺サイズでより確実にスキャンできる、より大きなモジュールを持っています。
go.pokernexus.com/appは、低QRバージョンにとどまるのに十分短いです。
中央にロゴを入れるとモジュールの一部が覆われるため、コードはそれらを補うためにエラー訂正に頼る必要があります。
中央のアイコンが追加されるとすぐに、ジェネレーターはレベルをH(約30%回復可能)に引き上げます。
これにより、PokerNexusクラブが中央に配置されてもスキャンが壊れることはありませんでした。
リダイレクトサービス
ホストは、小さなスタンドアロンHonoアプリによって提供されます。
誰かを送信できるすべての場所は、宛先として記述されています。
URLと、着信クエリ文字列をそれにコピーするかどうかを示すフラグです。
ts
interface Destination {
readonly url: string;
readonly query: "forward" | "drop";
}
const WEBSITE: Destination = {
url: "https://pokernexus.com",
query: "forward",
};
const APP_STORE: Destination = {
url: "https://apps.apple.com/app/id6800184564",
query: "drop",
};
const PLAY_STORE: Destination = {
url: "https://play.google.com/store/apps/details?id=com.pokernexus.app",
query: "drop",
};
ウェブサイトは2回表示されます。なぜなら、2つのパスはクエリ文字列を異なるように処理するからです。
クエリ文字列に関するセクションでその理由に戻ります。
ルーティングテーブルは、各パスをターゲットにマッピングします。
ターゲットは、固定の宛先または要求に基づいて宛先を選択する関数です。
tsexport type Target = Destination | ((request: Request) => Destination);
それは2つのエントリの長さです。
tsexport const links: Readonly<Record<string, Target>> = {
"/": { ...WEBSITE, query: "drop" },
"/app": (request) => storeFor(request.headers.get("user-agent")),
};
/ は訪問者をウェブサイトに送信し、/app は storeFor に問い合わせて、どの宛先がデバイスに適合するかを決定します。
決定はUser-Agentヘッダーのみに基づいて行われます。
ts
const BOT = /bot|crawl|spider|slurp|preview|facebookexternalhit/i;
const IOS = /iPhone|iPod|iPad/;
const ANDROID = /Android/;
export const storeFor = (userAgent: string | null | undefined): Destination => {
if (userAgent === null || userAgent === undefined) {
return WEBSITE;
}
if (BOT.test(userAgent)) {
return WEBSITE;
}
if (IOS.test(userAgent)) {
return APP_STORE;
}
if (ANDROID.test(userAgent)) {
return PLAY_STORE;
}
return WEBSITE;
};
確実な一致ではないすべての分岐は、ウェブサイトにフォールスルーします。
その非対称性は意図的です。
間違った答えがウェブサイトに着陸しても、機能するページになります。
一方、間違った答えがストアに着陸すると、アプリを実行できないデバイスのインストールプロンプトになります。
ボットは最初にチェックされます
これらのチェックの順序は簡単に見落とされます。
Googleのスマートフォンクローラーは、自分自身を電話として識別し、独自の名前を追加します。
したがって、AndroidクローラーのユーザーエージェントにはAndroidが含まれ、iOSのものにはiPhoneが含まれます。
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) ... (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
デバイスチェックが最初に行われた場合、Googlebotはストアのリストに送信され、ラップトップ上の人はウェブサイトに送信されます。
クローラーに人とは異なる宛先をサービスすることは、クローキングのように見えます。
そして、検索エンジンに推測してほしくないことです。
ボットを最初にチェックすると、すべてのクローラーがデスクトップ訪問者と同じ場所に送信されます。
ボットパターンは意図的に緩い部分文字列マッチです。
誤検出は1台の電話にストアリンクを失わせ、ウェブサイトに着陸させます。
誤検出はクローラーをストアに入れます。
それらの間違いは同じ大きさではないので、パターンはウェブサイトに向かって誤ります。
iPadの問題
iPad上のChromeは、ユーザーエージェントでデバイスを名前で指定します。
したがって、iOSパターン内のiPadはそれをキャッチします。
iPadOS 13以降、デフォルトでサイトのデスクトップバージョンを要求します。
そして、それが送信するユーザーエージェントは、Mac上のSafariが送信するものとバイト単位で同一です。
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.7.5 Safari/605.1.15
Chromiumベースのブラウザは、ユーザーエージェント文字列のより構造化された代替手段であるクライアントヒントを提供します。
これらは、Sec-CH-UA-Platform: "Android" や Sec-CH-UA-Mobile: ?1 のような Sec-CH-UA- で始まるリクエストヘッダーです。
これらは、ブラウザ、プラットフォーム、デバイスタイプを、1つの長い文字列ではなく、個別のフィールドで説明します。
しかし、Safariはそれらを送信しないため、iPadをMacから分離するヘッダーはありません。
このための私の最初の試みは、任意のMacintoshユーザーエージェントのための小さなインターロピタルページでした。
JavaScriptでnavigator.maxTouchPointsをチェックしました(iPadはタッチポイントを報告しますが、Macは報告しません)。
そして、正しい宛先でlocation.replaceを呼び出しました。
それは機能しましたが、同じプルリクエストで削除しました。
それは、すべてのMac訪問者に余分なラウンドトリップと空白ページのフラッシュを発生させました。
また、キャッシュ可能な302を、キャッシュされないように指示する必要がある200に置き換えました。
そして、そのホストにスクリプトを配置しました。そのホストの設計全体は、ルックアップテーブルとブランチです。
したがって、iPad上のSafariは意図的にウェブサイトに着陸します。
これは許容できるトレードオフです。
ウェブサイトには、AboutページにApp StoreとGoogle Playのバッジがあります。
したがって、フォールバックはこれらの訪問者にとって行き止まりではありません。
302、301ではない
キャッチオールハンドラーはパスを解決してリダイレクトします。
ts
app.on(["GET", "HEAD"], "/*", (c) => {
const destination = resolve(c.req.path, c.req.raw);
if (destination === null) {
return c.notFound();
}
c.header("Vary", "User-Agent");
return c.redirect(locationFor(destination, c.req.url), 302);
});
永続的なURLであるため、301に手を伸ばしたくなります。
しかし、永続的なリダイレクトはブラウザやその前にあるものすべてによってキャッシュされます。
手に渡った電話は、前の所有者の回答を保持し、後で印刷されたリンクを再配置しても、すでにスキャンした人には機能しなくなります。
印刷されたリンクの再配置は、リダイレクトホストを使用する主な理由の1つです。
したがって、リダイレクトは一時的です。
Varyヘッダーは、キャッシュレイヤーで同じ懸念に対処します。
デフォルトでは、キャッシュ(ブラウザ自身のキャッシュ、CDN、または企業ネットワーク上のプロキシ)は、URLのみをキーとして応答を保存します。
そして、同じURLへの次のリクエストにその保存された応答をサービスします。
Varyは、どのリクエストヘッダーが応答に影響を与えたかもキャッシュに伝えます。
したがって、それらのヘッダーが一致する場合にのみ、保存されたコピーを再利用します。
/appは同じURLに対して3つの異なる宛先を返します。
したがって、Vary: User-Agentがない場合、共有キャッシュはAndroidフォンに提供したリダイレクトを保存し、そのPlayストアリンクを次のiPhoneに提供する可能性があります。
HEADはGETと並行して応答されます。
なぜなら、リンクチェッカーはしばしばそれを使用するからです。
そして、印刷されたアドレスを指すリンクチェッカーは、電話が見るのと同じ302を見るべきです。
クエリ文字列
キャンペーンで印刷されたリンクにタグを付けること(?utm_source=cards&utm_medium=qr)は役立ちます。
したがって、ウェブサイトのフォールバックは着信クエリを転送します。
ストアの宛先はそれをドロップします。
tsexport const locationFor = (