1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

クリエイターのbioリンクで反応を測る:リダイレクトを自前で持つ最小実装

1
Posted at

bioリンクは「入口」、足りないのは「どの通りから来たか」

Instagram や X、TikTok のプロフィール欄に置くリンク(bioリンク)は、お店の入口のようなものです。入口が1つしかないと、「今日は100人来た」は分かっても、「駅前から来たのか、裏通りから来たのか」は分かりません。

bioに貼るURLを、自分でコントロールできるリダイレクトURLに変えると、通過した時点で記録を取れます。

https://go.example.com/bio?utm_source=instagram
        ↑ 自分のドメイン(ここで記録する)   ↑ どの入口か

やることは3つだけです。

  1. アクセスを受ける(リダイレクタ)
  2. 記録してから本来のURLへ302で送る
  3. あとでSQLで数える

たとえ話で言えば、リダイレクタは入口のドア、記録はドアに付けたカウンター、utm_source はお客さんが持ってきた「どの通りから来たか」の名札です。以下、Cloudflare Workers + D1 での実装例、集計SQL、ハマりどころを順に書きます。

最初に測るのは3つだけ

全部測ろうとすると破綻します。bioリンクの計測で最初に必要なのはこの3つです。

  • クリック数(日別・リンク別)
  • 流入元(どのSNS / どの投稿から来たか)
  • どのリンクが使われたか(slug)

「滞在時間」「コンバージョン」「離脱」は遷移先サイトの仕事です。リダイレクタが記録できるのは「入口を通過した」という事実まで、と割り切ると設計がとてもシンプルになります。

計測パターンの簡単な例

知りたいこと bioに置くURL 記録する値
どのSNSから来たか /bio?utm_source=instagram src
どの投稿から来たか /bio?utm_source=x&utm_campaign=spring2025 src, campaign
プロフィール文のA/B /bio-a, /bio-b slug
時期ごとの変化 変更なし(日付で集計) clicked_at

ポイントは、bioのURLに付けるパラメータは1〜2個までにすることです。パラメータが増えるほど入力ミスが増え、集計軸も散らかります。

実装1: リダイレクタ(Workers + TypeScript)

前提: Node.js 20 以降、Cloudflare アカウント、Wrangler。

npm create cloudflare@latest bio-link -- --type=hello-world
cd bio-link
npx wrangler d1 create bio-link-db

wrangler.toml に D1 バインディングを足します(database_id は上のコマンドが出力した値)。

name = "bio-link"
main = "src/index.ts"
compatibility_date = "2024-11-01"

[[d1_databases]]
binding = "DB"
database_name = "bio-link-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

スキーマは2テーブルだけです。

-- schema.sql
CREATE TABLE IF NOT EXISTS links (
  slug TEXT PRIMARY KEY,
  url  TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS clicks (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  slug         TEXT NOT NULL,
  clicked_at   TEXT NOT NULL,                 -- ISO 8601 (UTC)
  src          TEXT NOT NULL DEFAULT 'direct',
  campaign     TEXT NOT NULL DEFAULT '',
  country      TEXT NOT NULL DEFAULT '',
  ua           TEXT NOT NULL DEFAULT '',
  visitor_hash TEXT NOT NULL DEFAULT ''
);

CREATE INDEX IF NOT EXISTS idx_clicks_slug_time ON clicks (slug, clicked_at);
CREATE INDEX IF NOT EXISTS idx_clicks_src_time  ON clicks (src, clicked_at);

Worker 本体です。ポイントは「ボット除外」「記録でレスポンスを待たせない(waitUntil)」「計測用パラメータを遷移先に引き継ぐ」の3つです。

export interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const slug = url.pathname.replace(/^\/+/, "");

    if (!slug) return new Response("Not Found", { status: 404 });

    const link = await env.DB
      .prepare("SELECT url FROM links WHERE slug = ?1")
      .bind(slug)
      .first<{ url: string }>();

    if (!link) return new Response("Not Found", { status: 404 });

    const ua = request.headers.get("user-agent") ?? "";

    // リンクプレビュー(Slack / X / LINE などの unfurl)を除外する
    const isPreview = /bot|crawler|spider|preview|facebookexternalhit|Twitterbot|Slackbot/i.test(ua);

    if (!isPreview) {
      const src = url.searchParams.get("utm_source") ?? "direct";
      const campaign = url.searchParams.get("utm_campaign") ?? "";

      ctx.waitUntil(
        env.DB
          .prepare(
            `INSERT INTO clicks (slug, clicked_at, src, campaign, country, ua)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
          )
          .bind(
            slug,
            new Date().toISOString(),
            src,
            campaign,
            request.headers.get("cf-ipcountry") ?? "",
            ua.slice(0, 200)
          )
          .run()
      );
    }

    // 計測用パラメータは遷移先にも渡しておく
    const dest = new URL(link.url);
    for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
      const value = url.searchParams.get(key);
      if (value) dest.searchParams.set(key, value);
    }

    return new Response(null, {
      status: 302,
      headers: {
        Location: dest.toString(),
        "Cache-Control": "no-store", // キャッシュされると計測が止まる
      },
    });
  },
} satisfies ExportedHandler<Env>;

D1Database などの型は npx wrangler types で生成できます。

リンクを登録してデプロイします。

-- seed.sql
INSERT INTO links (slug, url) VALUES
  ('bio',   'https://example.com/links'),
  ('bio-a', 'https://example.com/links?variant=a'),
  ('bio-b', 'https://example.com/links?variant=b');
npx wrangler d1 execute bio-link-db --file=./schema.sql --remote
npx wrangler d1 execute bio-link-db --file=./seed.sql   --remote
npx wrangler deploy

これで bio に https://go.example.com/bio?utm_source=instagram を貼れば、クリックが clicks テーブルに入ります。

実装2: 集計はSQL 1本から始める

日別のクリック数:

SELECT substr(clicked_at, 1, 10) AS day,
       COUNT(*) AS clicks
FROM clicks
WHERE slug = 'bio'
GROUP BY day
ORDER BY day DESC
LIMIT 30;

流入元別(直近7日)。ISO 8601 の文字列をそのまま比較するとズレるので、datetime() でパースさせます。

SELECT src,
       COUNT(*) AS clicks
FROM clicks
WHERE datetime(clicked_at) >= datetime('now', '-7 days')
GROUP BY src
ORDER BY clicks DESC;

キャンペーン別:

SELECT campaign,
       COUNT(*) AS clicks
FROM clicks
WHERE datetime(clicked_at) >= datetime('now', '-30 days')
  AND campaign <> ''
GROUP BY campaign
ORDER BY clicks DESC;

出力はこうなります。

day         clicks
2025-03-18  128
2025-03-17  143

ユニーク訪問者を数えたい場合

COUNT(*) は延べクリック数です。同じ人が3回押せば3になります。ユニーク数が欲しいときは、生IPを保存せず、日次でローテーションするソルト付きハッシュを使うのが扱いやすい方法です。

async function visitorHash(ip: string, ua: string, day: string, salt: string) {
  const data = new TextEncoder().encode(`${ip}|${ua}|${day}|${salt}`);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

IPは request.headers.get("cf-connecting-ip") から取り、ソルトは wrangler secret put SALT で環境変数として持たせます。ソルトを日次で変えると、翌日には同じ人のハッシュが別の値になり、個人をまたいで追えなくなります。

SELECT substr(clicked_at, 1, 10) AS day,
       COUNT(DISTINCT visitor_hash) AS visitors
FROM clicks
GROUP BY day
ORDER BY day DESC;

なお、ハッシュ化しても個人に関わるデータとして扱われる場合があります。保存する項目、保持期間(例: 90日)、利用目的は、自分の地域のルールに沿って決めてください。

ハマりどころ

301リダイレクトを使う: 301 はブラウザに永続キャッシュされるため、以降のクリックがサーバーに届かず計測が止まります。計測用リンクは 302 を使い、Cache-Control: no-store を付けます。

リンクプレビューで数が膨らむ: Slack や X がURLを展開するときのリクエストは、人のクリックではありません。UAによるボット除外で大半は落とせますが、完璧にはなりません。「多少のノイズは乗る」前提で傾向を見ます。

Refererが空になる: アプリ内ブラウザでは Referer が落ちることが珍しくありません。だから utm_source を自分で付ける方式が効きます。Referer は補助情報としてだけ使います。

リダイレクトチェーンが増える: リダイレクタ → 短縮リンク → 遷移先、のように2段以上挟むと、計測位置によってはクリックが二重に記録されます。計測ポイントは1か所に決めます。

遷移先のパラメータを上書きする: 上のコードは遷移先の既存の utm_* を上書きします。遷移先で別の計測をしている場合は、set の前に存在チェックを入れてください。

リダイレクタを自前で持たない選択肢

「運用を増やしたくない」という場合は、短縮リンクの発行だけを外部に任せ、計測は遷移先で行う構成も現実的です。たとえば LinkBit で発行した短縮リンクをプロフィールに置き、遷移先URLに utm_source を付けておけば、リダイレクタを自前で運用しなくても流入元は追えます。

構成 向いているケース
自前リダイレクタ クリック単位で記録したい / 独自ドメインを使いたい
短縮リンク + 遷移先の計測 運用を増やしたくない / まず傾向だけ見たい

どちらも「bioのURLを自分でコントロールできる」という点は同じです。ここが崩れると、あとから計測を足すのが難しくなります。

小さく始めるためのチェックリスト

  • bioに置くURLを1本、自分でコントロールできる形にする
  • utm_source を1つだけ付ける(増やすなら utm_campaign まで)
  • 記録する列は slug / clicked_at / src の3つから
  • ボット除外と Cache-Control: no-store を入れる
  • 集計は日別と流入元別の2本のSQLで足りる

まずは1本のbioリンクを ?utm_source= 付きに変えて、1週間分の日別クリック数を見るところから始めるのがおすすめです。数字が動くのを確認できたら、A/Bやキャンペーン別の軸を足していくと、あとから壊れにくい計測になります。

#Qiita #TypeScript #CloudflareWorkers #アクセス解析 #D1

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?